From 09b2998119c53abaa0e712eb19fc359982e3a75a Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Wed, 18 Feb 2026 17:19:05 -0500 Subject: [PATCH 1/5] Changed Statement.multiline_command field from a string to a bool. --- CHANGELOG.md | 11 +++++----- cmd2/cmd2.py | 3 +-- cmd2/parsing.py | 8 ++++---- tests/test_cmd2.py | 6 +++--- tests/test_history.py | 8 ++++---- tests/test_parsing.py | 47 +++++++++++++++++++------------------------ 6 files changed, 39 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 291956618..5c8c03572 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,13 +33,14 @@ shell, and the option for a persistent bottom bar that can display realtime stat - `CompletionItem.descriptive_data` is now called `CompletionItem.table_row`. - `Cmd.default_sort_key` moved to `utils.DEFAULT_STR_SORT_KEY`. - Moved completion state data, which previously resided in `Cmd`, into other classes. - 1. `Cmd.matches_sorted` -> `Completions.is_sorted` and `Choices.is_sorted` - 1. `Cmd.completion_hint` -> `Completions.completion_hint` - 1. `Cmd.formatted_completions` -> `Completions.completion_table` - 1. `Cmd.matches_delimited` -> `Completions.is_delimited` - 1. `Cmd.allow_appended_space/allow_closing_quote` -> `Completions.allow_finalization` + - `Cmd.matches_sorted` -> `Completions.is_sorted` and `Choices.is_sorted` + - `Cmd.completion_hint` -> `Completions.completion_hint` + - `Cmd.formatted_completions` -> `Completions.completion_table` + - `Cmd.matches_delimited` -> `Completions.is_delimited` + - `Cmd.allow_appended_space/allow_closing_quote` -> `Completions.allow_finalization` - Removed `flag_based_complete` and `index_based_complete` functions since their functionality is already provided in arpgarse-based completion. + - Changed `Statement.multiline_command` field from a string to a bool. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 1cd11cb6e..771838246 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2907,8 +2907,7 @@ def _input_line_to_statement(self, line: str) -> Statement: # Make sure all input has been read and convert it to a Statement statement = self._complete_statement(line) - # If this is the first loop iteration, save the original line and stop - # combining multiline history entries in the remaining iterations. + # If this is the first loop iteration, save the original line if orig_line is None: orig_line = statement.raw diff --git a/cmd2/parsing.py b/cmd2/parsing.py index bf36498de..bbe99135a 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -129,8 +129,8 @@ class Statement(str): # noqa: SLOT000 # list of arguments to the command, not including any output redirection or terminators; quoted args remain quoted arg_list: list[str] = field(default_factory=list) - # if the command is a multiline command, the name of the command, otherwise empty - multiline_command: str = '' + # if the command is a multiline command + multiline_command: bool = False # the character which terminated the multiline command, if there was one terminator: str = '' @@ -510,7 +510,7 @@ def parse(self, line: str) -> Statement: arg_list = tokens[1:] # set multiline - multiline_command = command if command in self.multiline_commands else '' + multiline_command = command in self.multiline_commands # build the statement return Statement( @@ -580,7 +580,7 @@ def parse_command_only(self, rawinput: str) -> Statement: args = '' # set multiline - multiline_command = command if command in self.multiline_commands else '' + multiline_command = command in self.multiline_commands # build the statement return Statement(args, raw=rawinput, command=command, multiline_command=multiline_command) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 0d5165eb9..9725a6372 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1767,7 +1767,7 @@ def test_multiline_complete_statement_without_terminator(multiline_app, monkeypa statement = multiline_app._complete_statement(line) assert statement == args assert statement.command == command - assert statement.multiline_command == command + assert statement.multiline_command def test_multiline_complete_statement_with_unclosed_quotes(multiline_app, monkeypatch) -> None: @@ -1780,7 +1780,7 @@ def test_multiline_complete_statement_with_unclosed_quotes(multiline_app, monkey statement = multiline_app._complete_statement(line) assert statement == 'hi "partially open\nquotes\n" now closed' assert statement.command == 'orate' - assert statement.multiline_command == 'orate' + assert statement.multiline_command assert statement.terminator == ';' @@ -1797,7 +1797,7 @@ def test_multiline_input_line_to_statement(multiline_app, monkeypatch) -> None: assert statement.raw == 'orate hi\nperson\n\n' assert statement == 'hi person' assert statement.command == 'orate' - assert statement.multiline_command == 'orate' + assert statement.multiline_command def test_multiline_history_added(multiline_app, monkeypatch) -> None: diff --git a/tests/test_history.py b/tests/test_history.py index 7d4485af9..a26d93ace 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -85,7 +85,7 @@ def hist(): ' "raw": "first",\n' ' "command": "",\n' ' "arg_list": [],\n' - ' "multiline_command": "",\n' + ' "multiline_command": false,\n' ' "terminator": "",\n' ' "suffix": "",\n' ' "pipe_to": "",\n' @@ -99,7 +99,7 @@ def hist(): ' "raw": "second",\n' ' "command": "",\n' ' "arg_list": [],\n' - ' "multiline_command": "",\n' + ' "multiline_command": false,\n' ' "terminator": "",\n' ' "suffix": "",\n' ' "pipe_to": "",\n' @@ -113,7 +113,7 @@ def hist(): ' "raw": "third",\n' ' "command": "",\n' ' "arg_list": [],\n' - ' "multiline_command": "",\n' + ' "multiline_command": false,\n' ' "terminator": "",\n' ' "suffix": "",\n' ' "pipe_to": "",\n' @@ -127,7 +127,7 @@ def hist(): ' "raw": "fourth",\n' ' "command": "",\n' ' "arg_list": [],\n' - ' "multiline_command": "",\n' + ' "multiline_command": false,\n' ' "terminator": "",\n' ' "suffix": "",\n' ' "pipe_to": "",\n' diff --git a/tests/test_parsing.py b/tests/test_parsing.py index b7af37145..2a6401c9a 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -46,7 +46,7 @@ def test_parse_empty_string(parser) -> None: assert statement.raw == line assert statement.command == '' assert statement.arg_list == [] - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.terminator == '' assert statement.suffix == '' assert statement.pipe_to == '' @@ -64,7 +64,7 @@ def test_parse_empty_string_default(default_parser) -> None: assert statement.raw == line assert statement.command == '' assert statement.arg_list == [] - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.terminator == '' assert statement.suffix == '' assert statement.pipe_to == '' @@ -144,7 +144,7 @@ def test_parse_single_word(parser, line) -> None: assert not statement.arg_list assert statement.args == statement assert statement.raw == line - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.terminator == '' assert statement.suffix == '' assert statement.pipe_to == '' @@ -527,7 +527,7 @@ def test_parse_redirect_inside_terminator(parser) -> None: ) def test_parse_multiple_terminators(parser, line, terminator) -> None: statement = parser.parse(line) - assert statement.multiline_command == 'multiline' + assert statement.multiline_command assert statement == 'with | inside' assert statement.args == statement assert statement.argv == ['multiline', 'with', '|', 'inside'] @@ -538,7 +538,7 @@ def test_parse_multiple_terminators(parser, line, terminator) -> None: def test_parse_unfinished_multiliine_command(parser) -> None: line = 'multiline has > inside an unfinished command' statement = parser.parse(line) - assert statement.multiline_command == 'multiline' + assert statement.multiline_command assert statement.command == 'multiline' assert statement == 'has > inside an unfinished command' assert statement.args == statement @@ -550,7 +550,7 @@ def test_parse_unfinished_multiliine_command(parser) -> None: def test_parse_basic_multiline_command(parser) -> None: line = 'multiline foo\nbar\n\n' statement = parser.parse(line) - assert statement.multiline_command == 'multiline' + assert statement.multiline_command assert statement.command == 'multiline' assert statement == 'foo bar' assert statement.args == statement @@ -572,7 +572,7 @@ def test_parse_basic_multiline_command(parser) -> None: ) def test_parse_multiline_command_ignores_redirectors_within_it(parser, line, terminator) -> None: statement = parser.parse(line) - assert statement.multiline_command == 'multiline' + assert statement.multiline_command assert statement == 'has > inside' assert statement.args == statement assert statement.argv == ['multiline', 'has', '>', 'inside'] @@ -583,7 +583,7 @@ def test_parse_multiline_command_ignores_redirectors_within_it(parser, line, ter def test_parse_multiline_terminated_by_empty_line(parser) -> None: line = 'multiline command ends\n\n' statement = parser.parse(line) - assert statement.multiline_command == 'multiline' + assert statement.multiline_command assert statement.command == 'multiline' assert statement == 'command ends' assert statement.args == statement @@ -605,7 +605,7 @@ def test_parse_multiline_terminated_by_empty_line(parser) -> None: ) def test_parse_multiline_with_embedded_newline(parser, line, terminator) -> None: statement = parser.parse(line) - assert statement.multiline_command == 'multiline' + assert statement.multiline_command assert statement.command == 'multiline' assert statement == 'command "with\nembedded newline"' assert statement.args == statement @@ -617,7 +617,7 @@ def test_parse_multiline_with_embedded_newline(parser, line, terminator) -> None def test_parse_multiline_ignores_terminators_in_quotes(parser) -> None: line = 'multiline command "with term; ends" now\n\n' statement = parser.parse(line) - assert statement.multiline_command == 'multiline' + assert statement.multiline_command assert statement.command == 'multiline' assert statement == 'command "with term; ends" now' assert statement.args == statement @@ -694,7 +694,7 @@ def test_parse_alias_and_shortcut_expansion(parser, line, command, args) -> None def test_parse_alias_on_multiline_command(parser) -> None: line = 'anothermultiline has > inside an unfinished command' statement = parser.parse(line) - assert statement.multiline_command == 'multiline' + assert statement.multiline_command assert statement.command == 'multiline' assert statement.args == statement assert statement == 'has > inside an unfinished command' @@ -761,7 +761,7 @@ def test_parse_command_only_command_and_args(parser) -> None: assert statement.arg_list == [] assert statement.command == 'help' assert statement.command_and_args == line - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.raw == line assert statement.terminator == '' assert statement.suffix == '' @@ -778,7 +778,7 @@ def test_parse_command_only_strips_line(parser) -> None: assert statement.arg_list == [] assert statement.command == 'help' assert statement.command_and_args == line.strip() - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.raw == line assert statement.terminator == '' assert statement.suffix == '' @@ -795,7 +795,7 @@ def test_parse_command_only_expands_alias(parser) -> None: assert statement.arg_list == [] assert statement.command == 'run_pyscript' assert statement.command_and_args == 'run_pyscript foobar.py "somebody.py' - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.raw == line assert statement.terminator == '' assert statement.suffix == '' @@ -812,9 +812,8 @@ def test_parse_command_only_expands_shortcuts(parser) -> None: assert statement.arg_list == [] assert statement.command == 'shell' assert statement.command_and_args == 'shell cat foobar.txt' - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.raw == line - assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' assert statement.pipe_to == '' @@ -830,9 +829,8 @@ def test_parse_command_only_quoted_args(parser) -> None: assert statement.arg_list == [] assert statement.command == 'shell' assert statement.command_and_args == line.replace('l', 'shell ls -al') - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.raw == line - assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' assert statement.pipe_to == '' @@ -849,9 +847,8 @@ def test_parse_command_only_unclosed_quote(parser) -> None: assert statement.arg_list == [] assert statement.command == 'command' assert statement.command_and_args == line - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.raw == line - assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' assert statement.pipe_to == '' @@ -877,9 +874,8 @@ def test_parse_command_only_specialchars(parser, line, args) -> None: assert statement == args assert statement.args == args assert statement.command == 'help' - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.raw == line - assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' assert statement.pipe_to == '' @@ -910,9 +906,8 @@ def test_parse_command_only_empty(parser, line) -> None: assert statement.arg_list == [] assert statement.command == '' assert statement.command_and_args == '' - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.raw == line - assert statement.multiline_command == '' assert statement.terminator == '' assert statement.suffix == '' assert statement.pipe_to == '' @@ -924,7 +919,7 @@ def test_parse_command_only_multiline(parser) -> None: line = 'multiline with partially "open quotes and no terminator' statement = parser.parse_command_only(line) assert statement.command == 'multiline' - assert statement.multiline_command == 'multiline' + assert statement.multiline_command assert statement == 'with partially "open quotes and no terminator' assert statement.command_and_args == line assert statement.args == statement @@ -941,7 +936,7 @@ def test_statement_initialization() -> None: assert not statement.arg_list assert isinstance(statement.argv, list) assert not statement.argv - assert statement.multiline_command == '' + assert not statement.multiline_command assert statement.terminator == '' assert statement.suffix == '' assert isinstance(statement.pipe_to, str) From 78e239ef063f159364801fbf8ea23e5fc7e38cf4 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 19 Feb 2026 14:12:15 -0500 Subject: [PATCH 2/5] Optimized Statement class. - Made Statement.arg_list a property which generates the list on-demand. - Renamed Statement.output to Statement.redirector. - Renamed Statement.output_to to Statement.redirect_to. - Removed Statement.pipe_to since it's data can be stored in Statement.redirector and Statement.redirect_to. --- cmd2/cmd2.py | 43 +++--- cmd2/constants.py | 6 +- cmd2/history.py | 2 +- cmd2/parsing.py | 268 +++++++++++++++++++------------------ tests/test_history.py | 30 ++--- tests/test_parsing.py | 303 +++++++++++++++++------------------------- 6 files changed, 297 insertions(+), 355 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 771838246..f8edc9366 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -595,7 +595,7 @@ def _(event: Any) -> None: # pragma: no cover if os.path.exists(startup_script): script_cmd = f"run_script {su.quote(startup_script)}" if silence_startup_script: - script_cmd += f" {constants.REDIRECTION_OUTPUT} {os.devnull}" + script_cmd += f" {constants.REDIRECTION_OVERWRITE} {os.devnull}" self._startup_commands.append(script_cmd) # Transcript files to run instead of interactive command loop @@ -2140,7 +2140,7 @@ def _redirect_complete(self, text: str, line: str, begidx: int, endidx: int, com if prior_token == constants.REDIRECTION_PIPE: do_shell_completion = True - elif in_pipe or prior_token in (constants.REDIRECTION_OUTPUT, constants.REDIRECTION_APPEND): + elif in_pipe or prior_token in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND): do_path_completion = True prior_token = cur_token @@ -2190,14 +2190,14 @@ def _perform_completion( # Parse the command line to get the command token. command = '' if custom_settings is None: - statement = self.statement_parser.parse_command_only(line) - command = statement.command + partial_statement = self.statement_parser.parse_command_only(line) + command = partial_statement.command # Malformed command line (e.g. quoted command token) if not command: return Completions() - expanded_line = statement.command_and_args + expanded_line = partial_statement.command_and_args if not expanded_line[-1:].isspace(): # Unquoted trailing whitespace gets stripped by parse_command_only(). @@ -2642,8 +2642,8 @@ def parseline(self, line: str) -> tuple[str, str, str]: :param line: line read by prompt-toolkit :return: tuple containing (command, args, line) """ - statement = self.statement_parser.parse_command_only(line) - return statement.command, statement.args, statement.command_and_args + partial_statement = self.statement_parser.parse_command_only(line) + return partial_statement.command, partial_statement.args, partial_statement.command_and_args def onecmd_plus_hooks( self, @@ -2853,8 +2853,8 @@ def _complete_statement(self, line: str) -> Statement: except Cmd2ShlexError: # we have an unclosed quotation mark, let's parse only the command # and see if it's a multiline - statement = self.statement_parser.parse_command_only(line) - if not statement.multiline_command: + partial_statement = self.statement_parser.parse_command_only(line) + if not partial_statement.multiline_command: # not a multiline command, so raise the exception raise @@ -2929,13 +2929,11 @@ def _input_line_to_statement(self, line: str) -> Statement: statement.args, raw=orig_line, command=statement.command, - arg_list=statement.arg_list, multiline_command=statement.multiline_command, terminator=statement.terminator, suffix=statement.suffix, - pipe_to=statement.pipe_to, - output=statement.output, - output_to=statement.output_to, + redirector=statement.redirector, + redirect_to=statement.redirect_to, ) return statement @@ -3003,7 +3001,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # Don't return since we set some state variables at the end of the function pass - elif statement.pipe_to: + elif statement.redirector == constants.REDIRECTION_PIPE: # Create a pipe with read and write sides read_fd, write_fd = os.pipe() @@ -3027,7 +3025,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # For any stream that is a StdSim, we will use a pipe so we can capture its output proc = subprocess.Popen( # noqa: S602 - statement.pipe_to, + statement.redirect_to, stdin=subproc_stdin, stdout=subprocess.PIPE if isinstance(self.stdout, utils.StdSim) else self.stdout, # type: ignore[unreachable] stderr=subprocess.PIPE if isinstance(sys.stderr, utils.StdSim) else sys.stderr, @@ -3054,14 +3052,14 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: if stdouts_match: sys.stdout = self.stdout - elif statement.output: - if statement.output_to: + elif statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND): + if statement.redirect_to: # redirecting to a file # statement.output can only contain REDIRECTION_APPEND or REDIRECTION_OUTPUT - mode = 'a' if statement.output == constants.REDIRECTION_APPEND else 'w' + mode = 'a' if statement.redirector == constants.REDIRECTION_APPEND else 'w' try: # Use line buffering - new_stdout = cast(TextIO, open(su.strip_quotes(statement.output_to), mode=mode, buffering=1)) # noqa: SIM115 + new_stdout = cast(TextIO, open(su.strip_quotes(statement.redirect_to), mode=mode, buffering=1)) # noqa: SIM115 except OSError as ex: raise RedirectionError('Failed to redirect output') from ex @@ -3092,7 +3090,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: if stdouts_match: sys.stdout = self.stdout - if statement.output == constants.REDIRECTION_APPEND: + if statement.redirector == constants.REDIRECTION_APPEND: self.stdout.write(current_paste_buffer) self.stdout.flush() @@ -3110,7 +3108,10 @@ def _restore_output(self, statement: Statement, saved_redir_state: utils.Redirec """ if saved_redir_state.redirecting: # If we redirected output to the clipboard - if statement.output and not statement.output_to: + if ( + statement.redirector in (constants.REDIRECTION_OVERWRITE, constants.REDIRECTION_APPEND) + and not statement.redirect_to + ): self.stdout.seek(0) write_to_paste_buffer(self.stdout.read()) diff --git a/cmd2/constants.py b/cmd2/constants.py index f89a8dfbf..75c60662c 100644 --- a/cmd2/constants.py +++ b/cmd2/constants.py @@ -8,10 +8,10 @@ # Used for command parsing, output redirection, completion, and word breaks. Do not change. QUOTES = ['"', "'"] REDIRECTION_PIPE = '|' -REDIRECTION_OUTPUT = '>' +REDIRECTION_OVERWRITE = '>' REDIRECTION_APPEND = '>>' -REDIRECTION_CHARS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT] -REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OUTPUT, REDIRECTION_APPEND] +REDIRECTION_CHARS = [REDIRECTION_PIPE, REDIRECTION_OVERWRITE] +REDIRECTION_TOKENS = [REDIRECTION_PIPE, REDIRECTION_OVERWRITE, REDIRECTION_APPEND] COMMENT_CHAR = '#' MULTILINE_TERMINATOR = ';' diff --git a/cmd2/history.py b/cmd2/history.py index a9fdf85b4..c2b1e2cac 100644 --- a/cmd2/history.py +++ b/cmd2/history.py @@ -146,7 +146,7 @@ class to gain access to the historical record. """ # Used in JSON dictionaries - _history_version = '1.0.0' + _history_version = '4.0.0' _history_version_field = 'history_version' _history_items_field = 'history_items' diff --git a/cmd2/parsing.py b/cmd2/parsing.py index bbe99135a..543c9d29d 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -5,6 +5,7 @@ import sys from collections.abc import Iterable from dataclasses import ( + asdict, dataclass, field, ) @@ -90,11 +91,6 @@ class Macro: class Statement(str): # noqa: SLOT000 """String subclass with additional attributes to store the results of parsing. - The ``cmd`` module in the standard library passes commands around as a - string. To retain backwards compatibility, ``cmd2`` does the same. However, - we need a place to capture the additional output of the command parsing, so - we add our own attributes to this subclass. - Instances of this class should not be created by anything other than the [StatementParser.parse][cmd2.parsing.StatementParser.parse] method, nor should any of the attributes be modified once the object is created. @@ -117,38 +113,36 @@ class Statement(str): # noqa: SLOT000 [argv][cmd2.parsing.Statement.argv] for a trick which strips quotes off for you. """ - # the arguments, but not the command, nor the output redirection clauses. + # A space-delimited string containing the arguments to the command (quotes preserved). + # This does not include any output redirection clauses. + # Note: If a terminator is present, characters that would otherwise be + # redirectors (like '>') are treated as literal arguments if they appear + # before the terminator. args: str = '' - # string containing exactly what we input by the user + # The original, unmodified input string raw: str = '' - # the command, i.e. the first whitespace delimited word + # The resolved command name (after shortcut/alias expansion) command: str = '' - # list of arguments to the command, not including any output redirection or terminators; quoted args remain quoted - arg_list: list[str] = field(default_factory=list) - - # if the command is a multiline command + # Whether the command is recognized as a multiline-capable command multiline_command: bool = False - # the character which terminated the multiline command, if there was one + # The character which terminates the command/arguments portion of the input. + # While primarily used to signal the end of multiline commands, its presence + # defines the boundary between arguments and any subsequent redirection. terminator: str = '' - # characters appearing after the terminator but before output redirection, if any + # Characters appearing after the terminator but before output redirection suffix: str = '' - # if output was piped to a shell command, the shell command as a string - pipe_to: str = '' - - # if output was redirected, the redirection token, i.e. '>>' - output: str = '' + # The operator used to redirect output (e.g. '>', '>>', or '|'). + redirector: str = '' - # if output was redirected, the destination file token (quotes preserved) - output_to: str = '' - - # Used in JSON dictionaries - _args_field = 'args' + # The destination for the redirected output (a file path or a shell command). + # Quotes are preserved. + redirect_to: str = '' def __new__(cls, value: object, *_pos_args: Any, **_kw_args: Any) -> Self: """Create a new instance of Statement. @@ -169,38 +163,32 @@ def command_and_args(self) -> str: excluded, as are any command terminators. """ if self.command and self.args: - rtn = f'{self.command} {self.args}' - elif self.command: - # there were no arguments to the command - rtn = self.command - else: - rtn = '' - return rtn + return f"{self.command} {self.args}" + return self.command @property def post_command(self) -> str: """A string containing any ending terminator, suffix, and redirection chars.""" - rtn = '' + parts = [] if self.terminator: - rtn += self.terminator + parts.append(self.terminator) if self.suffix: - rtn += ' ' + self.suffix + parts.append(self.suffix) - if self.pipe_to: - rtn += ' | ' + self.pipe_to + if self.redirector: + parts.append(self.redirector) + if self.redirect_to: + parts.append(self.redirect_to) - if self.output: - rtn += ' ' + self.output - if self.output_to: - rtn += ' ' + self.output_to - - return rtn + return ' '.join(parts) @property def expanded_command_line(self) -> str: """Concatenate [cmd2.parsing.Statement.command_and_args]() and [cmd2.parsing.Statement.post_command]().""" - return self.command_and_args + self.post_command + # Use a space if there is a post_command that doesn't start with a terminator + sep = ' ' if self.post_command and not self.terminator else '' + return f"{self.command_and_args}{sep}{self.post_command}" @property def argv(self) -> list[str]: @@ -214,36 +202,69 @@ def argv(self) -> list[str]: If you want to strip quotes from the input, you can use ``argv[1:]``. """ if self.command: - rtn = [su.strip_quotes(self.command)] - rtn.extend(su.strip_quotes(cur_token) for cur_token in self.arg_list) - else: - rtn = [] + return [su.strip_quotes(self.command)] + [su.strip_quotes(arg) for arg in self.arg_list] - return rtn + return [] + + @property + def arg_list(self) -> list[str]: + """Return the arguments in a list (quotes preserved).""" + return shlex_split(self.args) def to_dict(self) -> dict[str, Any]: """Convert this Statement into a dictionary for use in persistent JSON history files.""" - return self.__dict__.copy() + return asdict(self) - @staticmethod - def from_dict(source_dict: dict[str, Any]) -> 'Statement': + @classmethod + def from_dict(cls, source_dict: dict[str, Any]) -> Self: """Restore a Statement from a dictionary. :param source_dict: source data dictionary (generated using to_dict()) :return: Statement object - :raises KeyError: if source_dict is missing required elements """ # value needs to be passed as a positional argument. It corresponds to the args field. try: - value = source_dict[Statement._args_field] - except KeyError as ex: - raise KeyError(f"Statement dictionary is missing {ex} field") from None + value = source_dict["args"] + except KeyError: + raise KeyError("Statement dictionary is missing 'args' field") from None + + # Filter out 'args' so it isn't passed twice + kwargs = {k: v for k, v in source_dict.items() if k != 'args'} + return cls(value, **kwargs) + + +@dataclass(frozen=True, slots=True) +class PartialStatement: + """A partially parsed command line. + + This separates the command from its arguments without validating + terminators, redirection, or quoted string completion. + + Note: + Unlike [cmd2.parsing.Statement][], this is a simple data object + and does not inherit from [str][]. + + """ + + # The resolved command name (after shortcut/alias expansion) + command: str + + # The remaining string after the command. May contain unclosed quotes + # or unprocessed redirection/terminator characters. + args: str - # Pass the rest at kwargs (minus args) - kwargs = source_dict.copy() - del kwargs[Statement._args_field] + # The original, unmodified input string + raw: str - return Statement(value, **kwargs) + # Whether the command is recognized as a multiline-capable command + multiline_command: bool + + @property + def command_and_args(self) -> str: + """Combine command and args with a space between them.""" + if self.command and self.args: + return f"{self.command} {self.args}" + return self.command class StatementParser: @@ -404,7 +425,6 @@ def parse(self, line: str) -> Statement: command = '' args = '' - arg_list = [] # lex the input into a list of tokens tokens = self.tokenize(line) @@ -433,7 +453,7 @@ def parse(self, line: str) -> Statement: # everything before the first terminator is the command and the args (command, args) = self._command_and_args(tokens[:terminator_pos]) - arg_list = tokens[1:terminator_pos] + # we will set the suffix later # remove all the tokens before and including the terminator tokens = tokens[terminator_pos + 1 :] @@ -445,12 +465,10 @@ def parse(self, line: str) -> Statement: # because redirectors can only be after a terminator command = testcommand args = testargs - arg_list = tokens[1:] tokens = [] - pipe_to = '' - output = '' - output_to = '' + redirector = '' + redirect_to = '' # Find which redirector character appears first in the command try: @@ -459,9 +477,9 @@ def parse(self, line: str) -> Statement: pipe_index = len(tokens) try: - redir_index = tokens.index(constants.REDIRECTION_OUTPUT) + overwrite_index = tokens.index(constants.REDIRECTION_OVERWRITE) except ValueError: - redir_index = len(tokens) + overwrite_index = len(tokens) try: append_index = tokens.index(constants.REDIRECTION_APPEND) @@ -469,34 +487,38 @@ def parse(self, line: str) -> Statement: append_index = len(tokens) # Check if output should be piped to a shell command - if pipe_index < redir_index and pipe_index < append_index: + if pipe_index < overwrite_index and pipe_index < append_index: + redirector = constants.REDIRECTION_PIPE + # Get the tokens for the pipe command and expand ~ where needed pipe_to_tokens = tokens[pipe_index + 1 :] utils.expand_user_in_tokens(pipe_to_tokens) # Build the pipe command line string - pipe_to = ' '.join(pipe_to_tokens) + redirect_to = ' '.join(pipe_to_tokens) # remove all the tokens after the pipe tokens = tokens[:pipe_index] # Check for output redirect/append - elif redir_index != append_index: - if redir_index < append_index: - output = constants.REDIRECTION_OUTPUT - output_index = redir_index + elif overwrite_index != append_index: + if overwrite_index < append_index: + redirector = constants.REDIRECTION_OVERWRITE + redirector_index = overwrite_index else: - output = constants.REDIRECTION_APPEND - output_index = append_index + redirector = constants.REDIRECTION_APPEND + redirector_index = append_index + + redirect_to_index = redirector_index + 1 # Check if we are redirecting to a file - if len(tokens) > output_index + 1: - unquoted_path = su.strip_quotes(tokens[output_index + 1]) + if len(tokens) > redirect_to_index: + unquoted_path = su.strip_quotes(tokens[redirect_to_index]) if unquoted_path: - output_to = utils.expand_user(tokens[output_index + 1]) + redirect_to = utils.expand_user(tokens[redirect_to_index]) # remove all the tokens after the output redirect - tokens = tokens[:output_index] + tokens = tokens[:redirector_index] if terminator: # whatever is left is the suffix @@ -507,83 +529,77 @@ def parse(self, line: str) -> Statement: if not command: # command could already have been set, if so, don't set it again (command, args) = self._command_and_args(tokens) - arg_list = tokens[1:] - - # set multiline - multiline_command = command in self.multiline_commands # build the statement return Statement( args, raw=line, command=command, - arg_list=arg_list, - multiline_command=multiline_command, + multiline_command=command in self.multiline_commands, terminator=terminator, suffix=suffix, - pipe_to=pipe_to, - output=output, - output_to=output_to, + redirector=redirector, + redirect_to=redirect_to, ) - def parse_command_only(self, rawinput: str) -> Statement: - """Parse input into a [cmd2.Statement][] object (partially). + def parse_command_only(self, rawinput: str) -> PartialStatement: + """Identify the command and arguments from raw input. + + Partially parse input into a [cmd2.PartialStatement][] object. The command is identified, and shortcuts and aliases are expanded. Multiline commands are identified, but terminators and output redirection are not parsed. - This method is used by completion code and therefore must not - generate an exception if there are unclosed quotes. - - The [cmd2.parsing.Statement][] object returned by this method can at most - contain values in the following attributes: - [cmd2.parsing.Statement.args][], [cmd2.parsing.Statement.raw][], - [cmd2.parsing.Statement.command][], - [cmd2.parsing.Statement.multiline_command][] + This method is optimized for completion code and gracefully handles + unclosed quotes without raising exceptions. - [cmd2.parsing.Statement.args][] will include all output redirection + [cmd2.parsing.PartialStatement.args][] will include all output redirection clauses and command terminators. - Different from [cmd2.parsing.StatementParser.parse][] this method - does not remove redundant whitespace within args. However, it does - ensure args has no leading or trailing whitespace. + Note: + Unlike [cmd2.parsing.StatementParser.parse][], this method + preserves internal whitespace within the args. It ensures + args has no leading whitespace, and it strips trailing + whitespace only if all quotes are closed. :param rawinput: the command line as entered by the user - :return: a new [cmd2.Statement][] object + :return: a [cmd2.PartialStatement][] object representing the split input + """ - # expand shortcuts and aliases + # Expand shortcuts and aliases line = self._expand(rawinput) command = '' args = '' match = self._command_pattern.search(line) + if match: - # we got a match, extract the command + # Extract the resolved command command = match.group(1) - # take everything from the end of the first match group to - # the end of the line as the arguments (stripping leading - # and unquoted trailing whitespace) - args = line[match.end(1) :].lstrip() - try: - shlex_split(args) - except ValueError: - # Unclosed quote. Leave trailing whitespace. - pass - else: - args = args.rstrip() - # if the command is empty that means the input was either empty - # or something weird like '>'. args should be empty if we couldn't - # parse a command - if not command or not args: - args = '' - - # set multiline - multiline_command = command in self.multiline_commands + # If the command is empty, the input was either empty or started with + # something like a redirector ('>') or terminator (';'). + if command: + # args is everything after the command match + args = line[match.end(1) :].lstrip() + + try: + # Check for closed quotes + shlex_split(args) + except ValueError: + # Unclosed quote: preserve trailing whitespace for completion context. + pass + else: + # Quotes are closed: strip trailing whitespace + args = args.rstrip() - # build the statement - return Statement(args, raw=rawinput, command=command, multiline_command=multiline_command) + return PartialStatement( + command=command, + args=args, + raw=rawinput, + multiline_command=command in self.multiline_commands, + ) def get_command_arg_list( self, command_name: str, to_parse: Statement | str, preserve_quotes: bool diff --git a/tests/test_history.py b/tests/test_history.py index a26d93ace..77ec78eca 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -77,20 +77,18 @@ def hist(): # Represents the hist fixture's JSON hist_json = ( '{\n' - ' "history_version": "1.0.0",\n' + ' "history_version": "4.0.0",\n' ' "history_items": [\n' ' {\n' ' "statement": {\n' ' "args": "",\n' ' "raw": "first",\n' ' "command": "",\n' - ' "arg_list": [],\n' ' "multiline_command": false,\n' ' "terminator": "",\n' ' "suffix": "",\n' - ' "pipe_to": "",\n' - ' "output": "",\n' - ' "output_to": ""\n' + ' "redirector": "",\n' + ' "redirect_to": ""\n' ' }\n' ' },\n' ' {\n' @@ -98,13 +96,11 @@ def hist(): ' "args": "",\n' ' "raw": "second",\n' ' "command": "",\n' - ' "arg_list": [],\n' ' "multiline_command": false,\n' ' "terminator": "",\n' ' "suffix": "",\n' - ' "pipe_to": "",\n' - ' "output": "",\n' - ' "output_to": ""\n' + ' "redirector": "",\n' + ' "redirect_to": ""\n' ' }\n' ' },\n' ' {\n' @@ -112,13 +108,11 @@ def hist(): ' "args": "",\n' ' "raw": "third",\n' ' "command": "",\n' - ' "arg_list": [],\n' ' "multiline_command": false,\n' ' "terminator": "",\n' ' "suffix": "",\n' - ' "pipe_to": "",\n' - ' "output": "",\n' - ' "output_to": ""\n' + ' "redirector": "",\n' + ' "redirect_to": ""\n' ' }\n' ' },\n' ' {\n' @@ -126,13 +120,11 @@ def hist(): ' "args": "",\n' ' "raw": "fourth",\n' ' "command": "",\n' - ' "arg_list": [],\n' ' "multiline_command": false,\n' ' "terminator": "",\n' ' "suffix": "",\n' - ' "pipe_to": "",\n' - ' "output": "",\n' - ' "output_to": ""\n' + ' "redirector": "",\n' + ' "redirect_to": ""\n' ' }\n' ' }\n' ' ]\n' @@ -365,7 +357,7 @@ def test_history_from_json(hist) -> None: invalid_ver_json = hist.to_json() History._history_version = backed_up_ver - expected_err = "Unsupported history file version: BAD_VERSION. This application uses version 1.0.0." + expected_err = f"Unsupported history file version: BAD_VERSION. This application uses version {History._history_version}." with pytest.raises(ValueError, match=expected_err): hist.from_json(invalid_ver_json) @@ -386,7 +378,6 @@ def histitem(): 'history', raw='help history', command='help', - arg_list=['history'], ) return HistoryItem(statement) @@ -487,7 +478,6 @@ def test_history_item_instantiate() -> None: 'history', raw='help history', command='help', - arg_list=['history'], ) with pytest.raises(TypeError): _ = HistoryItem() diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 2a6401c9a..3c9e388bd 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -49,9 +49,8 @@ def test_parse_empty_string(parser) -> None: assert not statement.multiline_command assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + assert statement.redirector == '' + assert statement.redirect_to == '' assert statement.command_and_args == line assert statement.argv == statement.arg_list @@ -67,9 +66,8 @@ def test_parse_empty_string_default(default_parser) -> None: assert not statement.multiline_command assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + assert statement.redirector == '' + assert statement.redirect_to == '' assert statement.command_and_args == line assert statement.argv == statement.arg_list @@ -140,16 +138,15 @@ def test_parse_single_word(parser, line) -> None: statement = parser.parse(line) assert statement.command == line assert statement == '' + assert statement.args == statement assert statement.argv == [su.strip_quotes(line)] assert not statement.arg_list - assert statement.args == statement assert statement.raw == line assert not statement.multiline_command assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + assert statement.redirector == '' + assert statement.redirect_to == '' assert statement.command_and_args == line @@ -237,9 +234,9 @@ def test_parse_comment(parser) -> None: def test_parse_embedded_comment_char(parser) -> None: command_str = 'hi ' + constants.COMMENT_CHAR + ' not a comment' statement = parser.parse(command_str) - assert statement.command == 'hi' assert statement == constants.COMMENT_CHAR + ' not a comment' assert statement.args == statement + assert statement.command == 'hi' assert statement.argv == shlex_split(command_str) assert statement.arg_list == statement.argv[1:] @@ -258,8 +255,9 @@ def test_parse_simple_pipe(parser, line) -> None: assert statement.args == statement assert statement.argv == ['simple'] assert not statement.arg_list - assert statement.pipe_to == 'piped' - assert statement.expanded_command_line == statement.command + ' | ' + statement.pipe_to + assert statement.redirector == constants.REDIRECTION_PIPE + assert statement.redirect_to == 'piped' + assert statement.expanded_command_line == statement.command + ' | ' + statement.redirect_to def test_parse_double_pipe_is_not_a_pipe(parser) -> None: @@ -270,7 +268,8 @@ def test_parse_double_pipe_is_not_a_pipe(parser) -> None: assert statement.args == statement assert statement.argv == ['double-pipe', '||', 'is', 'not', 'a', 'pipe'] assert statement.arg_list == statement.argv[1:] - assert not statement.pipe_to + assert not statement.redirector + assert not statement.redirect_to def test_parse_complex_pipe(parser) -> None: @@ -283,11 +282,12 @@ def test_parse_complex_pipe(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == '&' assert statement.suffix == 'sufx' - assert statement.pipe_to == 'piped' + assert statement.redirector == constants.REDIRECTION_PIPE + assert statement.redirect_to == 'piped' @pytest.mark.parametrize( - ('line', 'output'), + ('line', 'redirector'), [ ('help > out.txt', '>'), ('help>out.txt', '>'), @@ -295,14 +295,14 @@ def test_parse_complex_pipe(parser) -> None: ('help>>out.txt', '>>'), ], ) -def test_parse_redirect(parser, line, output) -> None: +def test_parse_redirect(parser, line, redirector) -> None: statement = parser.parse(line) assert statement.command == 'help' assert statement == '' assert statement.args == statement - assert statement.output == output - assert statement.output_to == 'out.txt' - assert statement.expanded_command_line == statement.command + ' ' + statement.output + ' ' + statement.output_to + assert statement.redirector == redirector + assert statement.redirect_to == 'out.txt' + assert statement.expanded_command_line == statement.command + ' ' + statement.redirector + ' ' + statement.redirect_to @pytest.mark.parametrize( @@ -320,8 +320,8 @@ def test_parse_redirect_with_args(parser, dest) -> None: assert statement.args == statement assert statement.argv == ['output', 'into'] assert statement.arg_list == statement.argv[1:] - assert statement.output == '>' - assert statement.output_to == dest + assert statement.redirector == '>' + assert statement.redirect_to == dest def test_parse_redirect_append(parser) -> None: @@ -332,8 +332,8 @@ def test_parse_redirect_append(parser) -> None: assert statement.args == statement assert statement.argv == ['output', 'appended', 'to'] assert statement.arg_list == statement.argv[1:] - assert statement.output == '>>' - assert statement.output_to == '/tmp/afile.txt' + assert statement.redirector == '>>' + assert statement.redirect_to == '/tmp/afile.txt' def test_parse_pipe_then_redirect(parser) -> None: @@ -346,9 +346,8 @@ def test_parse_pipe_then_redirect(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == ';' assert statement.suffix == 'sufx' - assert statement.pipe_to == 'pipethrume plz > afile.txt' - assert statement.output == '' - assert statement.output_to == '' + assert statement.redirector == constants.REDIRECTION_PIPE + assert statement.redirect_to == 'pipethrume plz > afile.txt' def test_parse_multiple_pipes(parser) -> None: @@ -361,9 +360,8 @@ def test_parse_multiple_pipes(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == ';' assert statement.suffix == 'sufx' - assert statement.pipe_to == 'pipethrume plz | grep blah' - assert statement.output == '' - assert statement.output_to == '' + assert statement.redirector == constants.REDIRECTION_PIPE + assert statement.redirect_to == 'pipethrume plz | grep blah' def test_redirect_then_pipe(parser) -> None: @@ -376,9 +374,8 @@ def test_redirect_then_pipe(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '>' - assert statement.output_to == 'file.txt' + assert statement.redirector == '>' + assert statement.redirect_to == 'file.txt' def test_append_then_pipe(parser) -> None: @@ -391,9 +388,8 @@ def test_append_then_pipe(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '>>' - assert statement.output_to == 'file.txt' + assert statement.redirector == '>>' + assert statement.redirect_to == 'file.txt' def test_append_then_redirect(parser) -> None: @@ -406,9 +402,8 @@ def test_append_then_redirect(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '>>' - assert statement.output_to == 'file.txt' + assert statement.redirector == '>>' + assert statement.redirect_to == 'file.txt' def test_redirect_then_append(parser) -> None: @@ -421,9 +416,8 @@ def test_redirect_then_append(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '>' - assert statement.output_to == 'file.txt' + assert statement.redirector == '>' + assert statement.redirect_to == 'file.txt' def test_redirect_to_quoted_string(parser) -> None: @@ -436,9 +430,8 @@ def test_redirect_to_quoted_string(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '>' - assert statement.output_to == '"file.txt"' + assert statement.redirector == '>' + assert statement.redirect_to == '"file.txt"' def test_redirect_to_single_quoted_string(parser) -> None: @@ -451,9 +444,8 @@ def test_redirect_to_single_quoted_string(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '>' - assert statement.output_to == "'file.txt'" + assert statement.redirector == '>' + assert statement.redirect_to == "'file.txt'" def test_redirect_to_empty_quoted_string(parser) -> None: @@ -466,9 +458,8 @@ def test_redirect_to_empty_quoted_string(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '>' - assert statement.output_to == '' + assert statement.redirector == '>' + assert statement.redirect_to == '' def test_redirect_to_empty_single_quoted_string(parser) -> None: @@ -481,20 +472,19 @@ def test_redirect_to_empty_single_quoted_string(parser) -> None: assert statement.arg_list == statement.argv[1:] assert statement.terminator == '' assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '>' - assert statement.output_to == '' + assert statement.redirector == '>' + assert statement.redirect_to == '' -def test_parse_output_to_paste_buffer(parser) -> None: - line = 'output to paste buffer >> ' +def test_parse_redirect_to_paste_buffer(parser) -> None: + line = 'redirect to paste buffer >> ' statement = parser.parse(line) - assert statement.command == 'output' + assert statement.command == 'redirect' assert statement == 'to paste buffer' assert statement.args == statement - assert statement.argv == ['output', 'to', 'paste', 'buffer'] + assert statement.argv == ['redirect', 'to', 'paste', 'buffer'] assert statement.arg_list == statement.argv[1:] - assert statement.output == '>>' + assert statement.redirector == '>>' def test_parse_redirect_inside_terminator(parser) -> None: @@ -654,8 +644,8 @@ def test_parse_redirect_to_unicode_filename(parser) -> None: assert statement.args == statement assert statement.argv == ['dir', 'home'] assert statement.arg_list == statement.argv[1:] - assert statement.output == '>' - assert statement.output_to == 'café' + assert statement.redirector == '>' + assert statement.redirect_to == 'café' def test_parse_unclosed_quotes(parser) -> None: @@ -694,15 +684,15 @@ def test_parse_alias_and_shortcut_expansion(parser, line, command, args) -> None def test_parse_alias_on_multiline_command(parser) -> None: line = 'anothermultiline has > inside an unfinished command' statement = parser.parse(line) + assert statement == 'has > inside an unfinished command' + assert statement.args == statement assert statement.multiline_command assert statement.command == 'multiline' - assert statement.args == statement - assert statement == 'has > inside an unfinished command' assert statement.terminator == '' @pytest.mark.parametrize( - ('line', 'output'), + ('line', 'redirector'), [ ('helpalias > out.txt', '>'), ('helpalias>out.txt', '>'), @@ -710,13 +700,13 @@ def test_parse_alias_on_multiline_command(parser) -> None: ('helpalias>>out.txt', '>>'), ], ) -def test_parse_alias_redirection(parser, line, output) -> None: +def test_parse_alias_redirection(parser, line, redirector) -> None: statement = parser.parse(line) assert statement.command == 'help' assert statement == '' assert statement.args == statement - assert statement.output == output - assert statement.output_to == 'out.txt' + assert statement.redirector == redirector + assert statement.redirect_to == 'out.txt' @pytest.mark.parametrize( @@ -731,7 +721,8 @@ def test_parse_alias_pipe(parser, line) -> None: assert statement.command == 'help' assert statement == '' assert statement.args == statement - assert statement.pipe_to == 'less' + assert statement.redirector == constants.REDIRECTION_PIPE + assert statement.redirect_to == 'less' @pytest.mark.parametrize( @@ -755,105 +746,63 @@ def test_parse_alias_terminator_no_whitespace(parser, line) -> None: def test_parse_command_only_command_and_args(parser) -> None: line = 'help history' - statement = parser.parse_command_only(line) - assert statement == 'history' - assert statement.args == statement - assert statement.arg_list == [] - assert statement.command == 'help' - assert statement.command_and_args == line - assert not statement.multiline_command - assert statement.raw == line - assert statement.terminator == '' - assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + partial_statement = parser.parse_command_only(line) + assert partial_statement.command == 'help' + assert partial_statement.args == 'history' + assert partial_statement.raw == line + assert not partial_statement.multiline_command + assert partial_statement.command_and_args == line def test_parse_command_only_strips_line(parser) -> None: line = ' help history ' - statement = parser.parse_command_only(line) - assert statement == 'history' - assert statement.args == statement - assert statement.arg_list == [] - assert statement.command == 'help' - assert statement.command_and_args == line.strip() - assert not statement.multiline_command - assert statement.raw == line - assert statement.terminator == '' - assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + partial_statement = parser.parse_command_only(line) + assert partial_statement.command == 'help' + assert partial_statement.args == 'history' + assert partial_statement.raw == line + assert not partial_statement.multiline_command + assert partial_statement.command_and_args == line.strip() def test_parse_command_only_expands_alias(parser) -> None: line = 'fake foobar.py "somebody.py' - statement = parser.parse_command_only(line) - assert statement == 'foobar.py "somebody.py' - assert statement.args == statement - assert statement.arg_list == [] - assert statement.command == 'run_pyscript' - assert statement.command_and_args == 'run_pyscript foobar.py "somebody.py' - assert not statement.multiline_command - assert statement.raw == line - assert statement.terminator == '' - assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + partial_statement = parser.parse_command_only(line) + assert partial_statement.command == 'run_pyscript' + assert partial_statement.args == 'foobar.py "somebody.py' + assert partial_statement.raw == line + assert not partial_statement.multiline_command + assert partial_statement.command_and_args == 'run_pyscript foobar.py "somebody.py' def test_parse_command_only_expands_shortcuts(parser) -> None: line = '!cat foobar.txt' - statement = parser.parse_command_only(line) - assert statement == 'cat foobar.txt' - assert statement.args == statement - assert statement.arg_list == [] - assert statement.command == 'shell' - assert statement.command_and_args == 'shell cat foobar.txt' - assert not statement.multiline_command - assert statement.raw == line - assert statement.terminator == '' - assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + partial_statement = parser.parse_command_only(line) + assert partial_statement.command == 'shell' + assert partial_statement.args == 'cat foobar.txt' + assert partial_statement.raw == line + assert not partial_statement.multiline_command + assert partial_statement.command_and_args == 'shell cat foobar.txt' def test_parse_command_only_quoted_args(parser) -> None: line = 'l "/tmp/directory with spaces/doit.sh"' - statement = parser.parse_command_only(line) - assert statement == 'ls -al "/tmp/directory with spaces/doit.sh"' - assert statement.args == statement - assert statement.arg_list == [] - assert statement.command == 'shell' - assert statement.command_and_args == line.replace('l', 'shell ls -al') - assert not statement.multiline_command - assert statement.raw == line - assert statement.terminator == '' - assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + partial_statement = parser.parse_command_only(line) + assert partial_statement.command == 'shell' + assert partial_statement.args == 'ls -al "/tmp/directory with spaces/doit.sh"' + assert partial_statement.raw == line + assert not partial_statement.multiline_command + assert partial_statement.command_and_args == line.replace('l', 'shell ls -al') def test_parse_command_only_unclosed_quote(parser) -> None: # Quoted trailing spaces will be preserved line = 'command with unclosed "quote ' - statement = parser.parse_command_only(line) - assert statement == 'with unclosed "quote ' - assert statement.args == statement - assert statement.arg_list == [] - assert statement.command == 'command' - assert statement.command_and_args == line - assert not statement.multiline_command - assert statement.raw == line - assert statement.terminator == '' - assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + partial_statement = parser.parse_command_only(line) + assert partial_statement.command == 'command' + assert partial_statement.args == 'with unclosed "quote ' + assert partial_statement.raw == line + assert not partial_statement.multiline_command + assert partial_statement.command_and_args == line @pytest.mark.parametrize( @@ -870,17 +819,12 @@ def test_parse_command_only_unclosed_quote(parser) -> None: ], ) def test_parse_command_only_specialchars(parser, line, args) -> None: - statement = parser.parse_command_only(line) - assert statement == args - assert statement.args == args - assert statement.command == 'help' - assert not statement.multiline_command - assert statement.raw == line - assert statement.terminator == '' - assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + partial_statement = parser.parse_command_only(line) + assert partial_statement.command == 'help' + assert partial_statement.args == args + assert partial_statement.raw == line + assert not partial_statement.multiline_command + assert partial_statement.command_and_args == 'help' + ' ' + args @pytest.mark.parametrize( @@ -900,55 +844,46 @@ def test_parse_command_only_specialchars(parser, line, args) -> None: ], ) def test_parse_command_only_empty(parser, line) -> None: - statement = parser.parse_command_only(line) - assert statement == '' - assert statement.args == statement - assert statement.arg_list == [] - assert statement.command == '' - assert statement.command_and_args == '' - assert not statement.multiline_command - assert statement.raw == line - assert statement.terminator == '' - assert statement.suffix == '' - assert statement.pipe_to == '' - assert statement.output == '' - assert statement.output_to == '' + partial_statement = parser.parse_command_only(line) + assert partial_statement.command == '' + assert partial_statement.args == '' + assert partial_statement.raw == line + assert not partial_statement.multiline_command + assert partial_statement.command_and_args == '' def test_parse_command_only_multiline(parser) -> None: line = 'multiline with partially "open quotes and no terminator' - statement = parser.parse_command_only(line) - assert statement.command == 'multiline' - assert statement.multiline_command - assert statement == 'with partially "open quotes and no terminator' - assert statement.command_and_args == line - assert statement.args == statement + partial_statement = parser.parse_command_only(line) + assert partial_statement.command == 'multiline' + assert partial_statement.args == 'with partially "open quotes and no terminator' + assert partial_statement.raw == line + assert partial_statement.multiline_command + assert partial_statement.command_and_args == line def test_statement_initialization() -> None: string = 'alias' statement = cmd2.Statement(string) - assert string == statement + assert statement == string assert statement.args == statement assert statement.raw == '' assert statement.command == '' assert isinstance(statement.arg_list, list) - assert not statement.arg_list + assert statement.arg_list == ['alias'] assert isinstance(statement.argv, list) assert not statement.argv assert not statement.multiline_command assert statement.terminator == '' assert statement.suffix == '' - assert isinstance(statement.pipe_to, str) - assert not statement.pipe_to - assert statement.output == '' - assert statement.output_to == '' + assert statement.redirector == '' + assert statement.redirect_to == '' def test_statement_is_immutable() -> None: string = 'foo' statement = cmd2.Statement(string) - assert string == statement + assert statement == string assert statement.args == statement assert statement.raw == '' with pytest.raises(dataclasses.FrozenInstanceError): @@ -971,7 +906,7 @@ def test_statement_as_dict(parser) -> None: # from_dict() should raise KeyError if required field is missing statement = parser.parse("command") statement_dict = statement.to_dict() - del statement_dict[Statement._args_field] + del statement_dict["args"] with pytest.raises(KeyError): Statement.from_dict(statement_dict) From 87fe6bac8765aa11fc550868712dfa4d63a12ac2 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 19 Feb 2026 14:47:25 -0500 Subject: [PATCH 3/5] Simplified recreation of resolved-macro Statement. --- cmd2/cmd2.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f8edc9366..d18b5acab 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2921,20 +2921,14 @@ def _input_line_to_statement(self, line: str) -> Statement: else: break - # This will be true when a macro was used + # If a macro was expanded, the 'statement' now contains the expanded text. + # We need to swap the 'raw' attribute back to the string the user typed + # so history shows the original line. if orig_line != statement.raw: - # Build a Statement that contains the resolved macro line - # but the originally typed line for its raw member. - statement = Statement( - statement.args, - raw=orig_line, - command=statement.command, - multiline_command=statement.multiline_command, - terminator=statement.terminator, - suffix=statement.suffix, - redirector=statement.redirector, - redirect_to=statement.redirect_to, - ) + statement_dict = statement.to_dict() + statement_dict["raw"] = orig_line + statement = Statement.from_dict(statement_dict) + return statement def _resolve_macro(self, statement: Statement) -> str | None: From a018b919eb644e2fc635257b887e4f768735394b Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 19 Feb 2026 15:45:12 -0500 Subject: [PATCH 4/5] Updated change log. --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c8c03572..fdc79cee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,12 @@ shell, and the option for a persistent bottom bar that can display realtime stat - `Cmd.allow_appended_space/allow_closing_quote` -> `Completions.allow_finalization` - Removed `flag_based_complete` and `index_based_complete` functions since their functionality is already provided in arpgarse-based completion. - - Changed `Statement.multiline_command` field from a string to a bool. + - Changed `Statement.multiline_command` from a string to a bool. + - Made `Statement.arg_list` a property which generates the list on-demand. + - Renamed `Statement.output` to `Statement.redirector`. + - Renamed `Statement.output_to` to `Statement.redirect_to`. + - Removed `Statement.pipe_to` since it can be handled by `Statement.redirector` and + `Statement.redirect_to`. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These From a5ac43b47c229f3db05fdc52185df19ea9ac4840 Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Thu, 19 Feb 2026 16:04:07 -0500 Subject: [PATCH 5/5] Updated change log. --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdc79cee8..ff22a0401 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ shell, and the option for a persistent bottom bar that can display realtime stat - Renamed `Statement.output_to` to `Statement.redirect_to`. - Removed `Statement.pipe_to` since it can be handled by `Statement.redirector` and `Statement.redirect_to`. + - Changed `StatementParser.parse_command_only()` to return a `PartialStatement` object. - Enhancements - New `cmd2.Cmd` parameters - **auto_suggest**: (boolean) if `True`, provide fish shell style auto-suggestions. These