From 287f3319fcb0af09f9f0864ff89f3ada72d24806 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Sat, 21 Feb 2026 13:55:23 -0500 Subject: [PATCH] Remove the Transcript Testing feature Transcript Testing was always an extremely brittle regression testing framework. We encourage all cmd2 users to use pytest for unit and integration tests and Robot Framework for acceptance tests. These frameworks are much more robust and you can use them to write tests that are less brittle. --- .github/CODEOWNERS | 1 - .pre-commit-config.yaml | 2 - CHANGELOG.md | 7 + README.md | 2 - cmd2/cmd2.py | 263 ++--------------- cmd2/transcript.py | 223 --------------- docs/api/index.md | 1 - docs/api/transcript.md | 3 - docs/examples/alternate_event_loops.md | 1 - docs/examples/getting_started.md | 1 - docs/features/history.md | 24 +- docs/features/hooks.md | 1 - docs/features/index.md | 1 - docs/features/os.md | 16 +- docs/features/startup_commands.md | 2 +- docs/features/transcripts.md | 182 ------------ docs/migrating/incompatibilities.md | 6 +- docs/migrating/why.md | 3 - examples/README.md | 2 - examples/cmd_as_argument.py | 5 +- examples/transcript_example.py | 84 ------ examples/transcripts/exampleSession.txt | 14 - examples/transcripts/pirate.transcript | 10 - examples/transcripts/quit.txt | 1 - examples/transcripts/transcript_regex.txt | 19 -- mkdocs.yml | 2 - tests/test_cmd2.py | 6 - tests/test_history.py | 14 +- tests/test_transcript.py | 328 ---------------------- tests/transcripts/bol_eol.txt | 6 - tests/transcripts/characterclass.txt | 6 - tests/transcripts/dotstar.txt | 4 - tests/transcripts/extension_notation.txt | 4 - tests/transcripts/failure.txt | 4 - tests/transcripts/from_cmdloop.txt | 44 --- tests/transcripts/multiline_no_regex.txt | 6 - tests/transcripts/multiline_regex.txt | 6 - tests/transcripts/no_output.txt | 7 - tests/transcripts/no_output_last.txt | 7 - tests/transcripts/singleslash.txt | 5 - tests/transcripts/slashes_escaped.txt | 6 - tests/transcripts/slashslash.txt | 4 - tests/transcripts/spaces.txt | 8 - tests/transcripts/word_boundaries.txt | 6 - 44 files changed, 55 insertions(+), 1292 deletions(-) delete mode 100644 cmd2/transcript.py delete mode 100644 docs/api/transcript.md delete mode 100644 docs/features/transcripts.md delete mode 100755 examples/transcript_example.py delete mode 100644 examples/transcripts/exampleSession.txt delete mode 100644 examples/transcripts/pirate.transcript delete mode 100644 examples/transcripts/quit.txt delete mode 100644 examples/transcripts/transcript_regex.txt delete mode 100644 tests/test_transcript.py delete mode 100644 tests/transcripts/bol_eol.txt delete mode 100644 tests/transcripts/characterclass.txt delete mode 100644 tests/transcripts/dotstar.txt delete mode 100644 tests/transcripts/extension_notation.txt delete mode 100644 tests/transcripts/failure.txt delete mode 100644 tests/transcripts/from_cmdloop.txt delete mode 100644 tests/transcripts/multiline_no_regex.txt delete mode 100644 tests/transcripts/multiline_regex.txt delete mode 100644 tests/transcripts/no_output.txt delete mode 100644 tests/transcripts/no_output_last.txt delete mode 100644 tests/transcripts/singleslash.txt delete mode 100644 tests/transcripts/slashes_escaped.txt delete mode 100644 tests/transcripts/slashslash.txt delete mode 100644 tests/transcripts/spaces.txt delete mode 100644 tests/transcripts/word_boundaries.txt diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 423c242ef..e8f629f0a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -44,7 +44,6 @@ cmd2/rich_utils.py @kmvanbrunt cmd2/string_utils.py @kmvanbrunt cmd2/styles.py @tleonhardt @kmvanbrunt cmd2/terminal_utils.py @kmvanbrunt -cmd2/transcript.py @tleonhardt cmd2/utils.py @tleonhardt @kmvanbrunt # Documentation diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c815eab7e..9033aab8f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,9 +6,7 @@ repos: - id: check-merge-conflict - id: check-toml - id: end-of-file-fixer - exclude: ^examples/transcripts/ - id: trailing-whitespace - exclude: ^examples/transcripts/ - repo: https://github.com/astral-sh/ruff-pre-commit rev: "v0.15.2" diff --git a/CHANGELOG.md b/CHANGELOG.md index 200c76fd0..15fd4f4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ prompt is displayed. each platform and provided utility functions related to `readline` - Added a dependency on `prompt-toolkit` and a new `cmd2.pt_utils` module with supporting utilities + - Removed **Transcript Testing** feature set along with the `history -t` option for generating + transcript files and the `cmd2.transcript` module + - This was an extremely brittle regression testing framework which should never have been + built into cmd2 + - We recommend using [pytest](https://docs.pytest.org/) for unit and integration tests and + [Robot Framework](https://robotframework.org/) for acceptance tests. Both of these + frameworks can be used to create tests which are far more reliable and less brittle. - Async specific: `prompt-toolkit` starts its own `asyncio` event loop in every `cmd2` application - Removed `cmd2.Cmd.terminal_lock` as it is no longer required to support things like diff --git a/README.md b/README.md index b88e081da..8d175abd6 100755 --- a/README.md +++ b/README.md @@ -77,8 +77,6 @@ command line argument parsing and execution of cmd2 scripting. - Text file scripting of your application with `run_script` (`@`) and `_relative_run_script` (`@@`) - Powerful and flexible built-in Python scripting of your application using the `run_pyscript` command -- Transcripts for use with built-in regression can be automatically generated from `history -t` or - `run_script -t` ## Installation diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index cfd29a342..c76172f08 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -13,7 +13,6 @@ - Settable environment parameters - Parsing commands with `argparse` argument parsers (flags) - Redirection to file or paste buffer (clipboard) with > or >> -- Easy transcript-based testing of applications (see examples/transcript_example.py) - Bash-style ``select`` available Note, if self.stdout is different than sys.stdout, then redirection with > and | @@ -52,7 +51,6 @@ IO, TYPE_CHECKING, Any, - ClassVar, TextIO, TypeVar, Union, @@ -282,12 +280,7 @@ class Cmd: """ DEFAULT_COMPLETEKEY = 'tab' - DEFAULT_EDITOR = utils.find_editor() - - # List for storing transcript test file names - testfiles: ClassVar[list[str]] = [] - DEFAULT_PROMPT = '(Cmd) ' def __init__( @@ -314,7 +307,6 @@ def __init__( startup_script: str = '', suggest_similar_command: bool = False, terminators: list[str] | None = None, - transcript_files: list[str] | None = None, ) -> None: """Easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package. @@ -322,8 +314,7 @@ def __init__( :param stdin: alternate input file object, if not specified, sys.stdin is used :param stdout: alternate output file object, if not specified, sys.stdout is used :param allow_cli_args: if ``True``, then [cmd2.Cmd.__init__][] will process command - line arguments as either commands to be run or, if ``-t`` or - ``--test`` are given, transcript files to run. This should be + line arguments as either commands to be run. This should be set to ``False`` if your application parses its own command line arguments. :param allow_clipboard: If False, cmd2 will disable clipboard interactions @@ -364,10 +355,6 @@ def __init__( is a semicolon. If your app only contains single-line commands and you want terminators to be treated as literals by the parser, then set this to an empty list. - :param transcript_files: pass a list of transcript files to be run on initialization. - This allows running transcript tests when ``allow_cli_args`` - is ``False``. If ``allow_cli_args`` is ``True`` this parameter - is ignored. """ # Check if py or ipy need to be disabled in this instance if not include_py: @@ -595,23 +582,14 @@ def _(event: Any) -> None: # pragma: no cover script_cmd += f" {constants.REDIRECTION_OVERWRITE} {os.devnull}" self._startup_commands.append(script_cmd) - # Transcript files to run instead of interactive command loop - self._transcript_files: list[str] | None = None - # Check for command line args if allow_cli_args: parser = argparse_custom.DEFAULT_ARGUMENT_PARSER() - parser.add_argument('-t', '--test', action="store_true", help='Test against transcript(s) in FILE (wildcards OK)') - callopts, callargs = parser.parse_known_args() + _callopts, callargs = parser.parse_known_args() - # If transcript testing was called for, use other arguments as transcript files - if callopts.test: - self._transcript_files = callargs # If commands were supplied at invocation, then add them to the command queue - elif callargs: + if callargs: self._startup_commands.extend(callargs) - elif transcript_files: - self._transcript_files = transcript_files # Set the pager(s) for use when displaying output using a pager if sys.platform.startswith('win'): @@ -1252,8 +1230,7 @@ def _completion_supported(self) -> bool: def visible_prompt(self) -> str: """Read-only property to get the visible prompt with any ANSI style sequences stripped. - Used by transcript testing to make it easier and more reliable when users are doing things like - coloring the prompt. + Useful for test frameworks doing comparisons without having to worry about color/style. :return: the stripped prompt """ @@ -4823,13 +4800,6 @@ def _build_history_parser(cls) -> Cmd2ArgumentParser: help='output commands to a script file, implies -s', completer=cls.path_complete, ) - history_action_group.add_argument( - '-t', - '--transcript', - metavar='TRANSCRIPT_FILE', - help='create a transcript file by re-running the commands, implies both -r and -s', - completer=cls.path_complete, - ) history_action_group.add_argument('-c', '--clear', action='store_true', help='clear all history') history_format_group = history_parser.add_argument_group(title='formatting') @@ -4879,13 +4849,13 @@ def do_history(self, args: argparse.Namespace) -> bool | None: # -v must be used alone with no other options if args.verbose: # noqa: SIM102 - if args.clear or args.edit or args.output_file or args.run or args.transcript or args.expanded or args.script: + if args.clear or args.edit or args.output_file or args.run or args.expanded or args.script: self.poutput("-v cannot be used with any other options") return None # -s and -x can only be used if none of these options are present: [-c -r -e -o -t] - if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run or args.transcript): - self.poutput("-s and -x cannot be used with -c, -r, -e, -o, or -t") + if (args.script or args.expanded) and (args.clear or args.edit or args.output_file or args.run): + self.poutput("-s and -x cannot be used with -c, -r, -e, or -o") return None if args.clear: @@ -4948,9 +4918,6 @@ def do_history(self, args: argparse.Namespace) -> bool | None: else: self.pfeedback(f"{len(history)} command{plural} saved to {full_path}") self.last_result = True - elif args.transcript: - # self.last_result will be set by _generate_transcript() - self._generate_transcript(list(history.values()), args.transcript) else: # Display the history items retrieved for idx, hi in history.items(): @@ -5085,101 +5052,6 @@ def _persist_history(self) -> None: except OSError as ex: self.perror(f"Cannot write persistent history file '{self.persistent_history_file}': {ex}") - def _generate_transcript( - self, - history: list[HistoryItem] | list[str], - transcript_file: str, - *, - add_to_history: bool = True, - ) -> None: - """Generate a transcript file from a given history of commands.""" - self.last_result = False - - # Validate the transcript file path to make sure directory exists and write access is available - transcript_path = os.path.abspath(os.path.expanduser(transcript_file)) - transcript_dir = os.path.dirname(transcript_path) - if not os.path.isdir(transcript_dir) or not os.access(transcript_dir, os.W_OK): - self.perror(f"'{transcript_dir}' is not a directory or you don't have write access") - return - - commands_run = 0 - try: - with self.sigint_protection: - # Disable echo while we manually redirect stdout to a StringIO buffer - saved_echo = self.echo - saved_stdout = self.stdout - self.echo = False - - # The problem with supporting regular expressions in transcripts - # is that they shouldn't be processed in the command, just the output. - # In addition, when we generate a transcript, any slashes in the output - # are not really intended to indicate regular expressions, so they should - # be escaped. - # - # We have to jump through some hoops here in order to catch the commands - # separately from the output and escape the slashes in the output. - transcript = '' - for history_item in history: - # build the command, complete with prompts. When we replay - # the transcript, we look for the prompts to separate - # the command from the output - first = True - command = '' - if isinstance(history_item, HistoryItem): - history_item = history_item.raw # noqa: PLW2901 - for line in history_item.splitlines(): - if first: - command += f"{self.prompt}{line}\n" - first = False - else: - command += f"{self.continuation_prompt}{line}\n" - transcript += command - - # Use a StdSim object to capture output - stdsim = utils.StdSim(self.stdout) - self.stdout = cast(TextIO, stdsim) - - # then run the command and let the output go into our buffer - try: - stop = self.onecmd_plus_hooks( - history_item, - add_to_history=add_to_history, - raise_keyboard_interrupt=True, - ) - except KeyboardInterrupt as ex: - self.perror(ex) - stop = True - - commands_run += 1 - - # add the regex-escaped output to the transcript - transcript += stdsim.getvalue().replace('/', r'\/') - - # check if we are supposed to stop - if stop: - break - finally: - with self.sigint_protection: - # Restore altered attributes to their original state - self.echo = saved_echo - self.stdout = saved_stdout - - # Check if all commands ran - if commands_run < len(history): - self.pwarning(f"Command {commands_run} triggered a stop and ended transcript generation early") - - # finally, we can write the transcript out to the file - try: - with open(transcript_path, 'w') as fout: - fout.write(transcript) - except OSError as ex: - self.perror(f"Error saving transcript file '{transcript_path}': {ex}") - else: - # and let the user know what we did - plural = 'command and its output' if commands_run == 1 else 'commands and their outputs' - self.pfeedback(f"{commands_run} {plural} saved to transcript file '{transcript_path}'") - self.last_result = True - @classmethod def _build_edit_parser(cls) -> Cmd2ArgumentParser: edit_description = "Run a text editor and optionally open a file with it." @@ -5247,16 +5119,7 @@ def _build_base_run_script_parser(cls) -> Cmd2ArgumentParser: @classmethod def _build_run_script_parser(cls) -> Cmd2ArgumentParser: - run_script_parser = cls._build_base_run_script_parser() - run_script_parser.add_argument( - '-t', - '--transcript', - metavar='TRANSCRIPT_FILE', - help='record the output of the script as a transcript file', - completer=cls.path_complete, - ) - - return run_script_parser + return cls._build_base_run_script_parser() @with_argparser(_build_run_script_parser) def do_run_script(self, args: argparse.Namespace) -> bool | None: @@ -5297,29 +5160,18 @@ def do_run_script(self, args: argparse.Namespace) -> bool | None: try: self._script_dir.append(os.path.dirname(expanded_path)) - - if args.transcript: - # self.last_result will be set by _generate_transcript() - self._generate_transcript( - script_commands, - os.path.expanduser(args.transcript), - add_to_history=self.scripts_add_to_history, - ) - else: - stop = self.runcmds_plus_hooks( - script_commands, - add_to_history=self.scripts_add_to_history, - stop_on_keyboard_interrupt=True, - ) - self.last_result = True - return stop - + stop = self.runcmds_plus_hooks( + script_commands, + add_to_history=self.scripts_add_to_history, + stop_on_keyboard_interrupt=True, + ) + self.last_result = True + return stop finally: with self.sigint_protection: # Check if a script dir was added before an exception occurred if orig_script_dir_count != len(self._script_dir): self._script_dir.pop() - return None @classmethod def _build_relative_run_script_parser(cls) -> Cmd2ArgumentParser: @@ -5357,70 +5209,6 @@ def do__relative_run_script(self, args: argparse.Namespace) -> bool | None: # self.last_result will be set by do_run_script() return self.do_run_script(su.quote(relative_path)) - def _run_transcript_tests(self, transcript_paths: list[str]) -> None: - """Run transcript tests for provided file(s). - - This is called when either -t is provided on the command line or the transcript_files argument is provided - during construction of the cmd2.Cmd instance. - - :param transcript_paths: list of transcript test file paths - """ - import time - import unittest - - import cmd2 - - from .transcript import ( - Cmd2TestCase, - ) - - class TestMyAppCase(Cmd2TestCase): - cmdapp = self - - # Validate that there is at least one transcript file - transcripts_expanded = utils.files_from_glob_patterns(transcript_paths, access=os.R_OK) - if not transcripts_expanded: - self.perror('No test files found - nothing to test') - self.exit_code = 1 - return - - verinfo = ".".join(map(str, sys.version_info[:3])) - num_transcripts = len(transcripts_expanded) - plural = '' if len(transcripts_expanded) == 1 else 's' - self.poutput( - Rule("cmd2 transcript test", characters=self.ruler, style=Style.null()), - style=Style(bold=True), - ) - self.poutput(f'platform {sys.platform} -- Python {verinfo}, cmd2-{cmd2.__version__}') - self.poutput(f'cwd: {os.getcwd()}') - self.poutput(f'cmd2 app: {sys.argv[0]}') - self.poutput(f'collected {num_transcripts} transcript{plural}', style=Style(bold=True)) - - self.__class__.testfiles = transcripts_expanded - sys.argv = [sys.argv[0]] # the --test argument upsets unittest.main() - testcase = TestMyAppCase() - stream = cast(TextIO, utils.StdSim(sys.stderr)) - runner = unittest.TextTestRunner(stream=stream) - start_time = time.time() - test_results = runner.run(testcase) - execution_time = time.time() - start_time - if test_results.wasSuccessful(): - self.perror(stream.read(), end="", style=None) - finish_msg = f'{num_transcripts} transcript{plural} passed in {execution_time:.3f} seconds' - self.psuccess(Rule(finish_msg, characters=self.ruler, style=Style.null())) - else: - # Strip off the initial traceback which isn't particularly useful for end users - error_str = stream.read() - end_of_trace = error_str.find('AssertionError:') - file_offset = error_str[end_of_trace:].find('File ') - start = end_of_trace + file_offset - - # But print the transcript file name and line number followed by what was expected and what was observed - self.perror(error_str[start:]) - - # Return a failure error code to support automated transcript-based testing - self.exit_code = 1 - def async_alert(self, alert_msg: str, new_prompt: str | None = None) -> None: """Display an important message to the user while they are at a command line prompt. @@ -5616,7 +5404,6 @@ def cmdloop(self, intro: RenderableType = '') -> int: """Deal with extra features provided by cmd2, this is an outer wrapper around _cmdloop(). _cmdloop() provides the main loop. This provides the following extra features provided by cmd2: - - transcript testing - intro banner - exit code @@ -5646,20 +5433,16 @@ def cmdloop(self, intro: RenderableType = '') -> int: func() self.preloop() - # If transcript-based regression testing was requested, then do that instead of the main loop - if self._transcript_files is not None: - self._run_transcript_tests([os.path.expanduser(tf) for tf in self._transcript_files]) - else: - # If an intro was supplied in the method call, allow it to override the default - if intro: - self.intro = intro + # If an intro was supplied in the method call, allow it to override the default + if intro: + self.intro = intro - # Print the intro, if there is one, right after the preloop - if self.intro: - self.poutput(self.intro) + # Print the intro, if there is one, right after the preloop + if self.intro: + self.poutput(self.intro) - # And then call _cmdloop() to enter the main loop - self._cmdloop() + # And then call _cmdloop() to enter the main loop + self._cmdloop() # Run the postloop() no matter what for func in self._postloop_hooks: diff --git a/cmd2/transcript.py b/cmd2/transcript.py deleted file mode 100644 index cba5067cc..000000000 --- a/cmd2/transcript.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Machinery for running and validating transcripts. - -If the user wants to run a transcript (see docs/transcript.rst), -we need a mechanism to run each command in the transcript as -a unit test, comparing the expected output to the actual output. - -This file contains the class necessary to make that work. This -class is used in cmd2.py::run_transcript_tests() -""" - -import re -import unittest -from collections.abc import Iterator -from typing import ( - TYPE_CHECKING, - Optional, - TextIO, - cast, -) - -from . import string_utils as su -from . import utils - -if TYPE_CHECKING: # pragma: no cover - from cmd2 import Cmd - - -class Cmd2TestCase(unittest.TestCase): - """A unittest class used for transcript testing. - - Subclass this, setting CmdApp, to make a unittest.TestCase class - that will execute the commands in a transcript file and expect the - results shown. - - See transcript_example.py - """ - - cmdapp: Optional['Cmd'] = None - - def setUp(self) -> None: - """Instructions that will be executed before each test method.""" - if self.cmdapp: - self._fetch_transcripts() - - # Trap stdout - self._orig_stdout = self.cmdapp.stdout - self.cmdapp.stdout = cast(TextIO, utils.StdSim(self.cmdapp.stdout)) - - def tearDown(self) -> None: - """Instructions that will be executed after each test method.""" - if self.cmdapp: - # Restore stdout - self.cmdapp.stdout = self._orig_stdout - - def runTest(self) -> None: # was testall # noqa: N802 - """Override of the default runTest method for the unittest.TestCase class.""" - if self.cmdapp: - its = sorted(self.transcripts.items()) - for fname, transcript in its: - self._test_transcript(fname, transcript) - - def _fetch_transcripts(self) -> None: - self.transcripts = {} - testfiles = cast(list[str], getattr(self.cmdapp, 'testfiles', [])) - for fname in testfiles: - with open(fname) as tfile: - self.transcripts[fname] = iter(tfile.readlines()) - - def _test_transcript(self, fname: str, transcript: Iterator[str]) -> None: - if self.cmdapp is None: - return - - line_num = 0 - finished = False - line = su.strip_style(next(transcript)) - line_num += 1 - while not finished: - # Scroll forward to where actual commands begin - while not line.startswith(self.cmdapp.visible_prompt): - try: - line = su.strip_style(next(transcript)) - except StopIteration: - finished = True - break - line_num += 1 - command_parts = [line[len(self.cmdapp.visible_prompt) :]] - try: - line = next(transcript) - except StopIteration: - line = '' - line_num += 1 - # Read the entirety of a multi-line command - while line.startswith(self.cmdapp.continuation_prompt): - command_parts.append(line[len(self.cmdapp.continuation_prompt) :]) - try: - line = next(transcript) - except StopIteration as exc: - msg = f'Transcript broke off while reading command beginning at line {line_num} with\n{command_parts[0]}' - raise StopIteration(msg) from exc - line_num += 1 - command = ''.join(command_parts) - # Send the command into the application and capture the resulting output - stop = self.cmdapp.onecmd_plus_hooks(command) - result = self.cmdapp.stdout.read() - stop_msg = 'Command indicated application should quit, but more commands in transcript' - # Read the expected result from transcript - if su.strip_style(line).startswith(self.cmdapp.visible_prompt): - message = f'\nFile {fname}, line {line_num}\nCommand was:\n{command}\nExpected: (nothing)\nGot:\n{result}\n' - assert not result.strip(), message # noqa: S101 - # If the command signaled the application to quit there should be no more commands - assert not stop, stop_msg # noqa: S101 - continue - expected_parts = [] - while not su.strip_style(line).startswith(self.cmdapp.visible_prompt): - expected_parts.append(line) - try: - line = next(transcript) - except StopIteration: - finished = True - break - line_num += 1 - - if stop: - # This should only be hit if the command that set stop to True had output text - assert finished, stop_msg # noqa: S101 - - # transform the expected text into a valid regular expression - expected = ''.join(expected_parts) - expected = self._transform_transcript_expected(expected) - message = f'\nFile {fname}, line {line_num}\nCommand was:\n{command}\nExpected:\n{expected}\nGot:\n{result}\n' - assert re.match(expected, result, re.MULTILINE | re.DOTALL), message # noqa: S101 - - def _transform_transcript_expected(self, s: str) -> str: - r"""Parse the string with slashed regexes into a valid regex. - - Given a string like: - - Match a 10 digit phone number: /\d{3}-\d{3}-\d{4}/ - - Turn it into a valid regular expression which matches the literal text - of the string and the regular expression. We have to remove the slashes - because they differentiate between plain text and a regular expression. - Unless the slashes are escaped, in which case they are interpreted as - plain text, or there is only one slash, which is treated as plain text - also. - - Check the tests in tests/test_transcript.py to see all the edge - cases. - """ - regex = '' - start = 0 - - while True: - (regex, first_slash_pos, start) = self._escaped_find(regex, s, start, False) - if first_slash_pos == -1: - # no more slashes, add the rest of the string and bail - regex += re.escape(s[start:]) - break - # there is a slash, add everything we have found so far - # add stuff before the first slash as plain text - regex += re.escape(s[start:first_slash_pos]) - start = first_slash_pos + 1 - # and go find the next one - (regex, second_slash_pos, start) = self._escaped_find(regex, s, start, True) - if second_slash_pos > 0: - # add everything between the slashes (but not the slashes) - # as a regular expression - regex += s[start:second_slash_pos] - # and change where we start looking for slashed on the - # turn through the loop - start = second_slash_pos + 1 - else: - # No closing slash, we have to add the first slash, - # and the rest of the text - regex += re.escape(s[start - 1 :]) - break - return regex - - @staticmethod - def _escaped_find(regex: str, s: str, start: int, in_regex: bool) -> tuple[str, int, int]: - """Find the next slash in {s} after {start} that is not preceded by a backslash. - - If we find an escaped slash, add everything up to and including it to regex, - updating {start}. {start} therefore serves two purposes, tells us where to start - looking for the next thing, and also tells us where in {s} we have already - added things to {regex} - - {in_regex} specifies whether we are currently searching in a regex, we behave - differently if we are or if we aren't. - """ - while True: - pos = s.find('/', start) - if pos == -1: - # no match, return to caller - break - if pos == 0: - # slash at the beginning of the string, so it can't be - # escaped. We found it. - break - # check if the slash is preceded by a backslash - if s[pos - 1 : pos] == '\\': - # it is. - if in_regex: - # add everything up to the backslash as a - # regular expression - regex += s[start : pos - 1] - # skip the backslash, and add the slash - regex += s[pos] - else: - # add everything up to the backslash as escaped - # plain text - regex += re.escape(s[start : pos - 1]) - # and then add the slash as escaped - # plain text - regex += re.escape(s[pos]) - # update start to show we have handled everything - # before it - start = pos + 1 - # and continue to look - else: - # slash is not escaped, this is what we are looking for - break - return regex, pos, start diff --git a/docs/api/index.md b/docs/api/index.md index 47eaf259c..10fd50472 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -31,5 +31,4 @@ incremented according to the [Semantic Version Specification](https://semver.org - [cmd2.string_utils](./string_utils.md) - string utility functions - [cmd2.styles](./styles.md) - cmd2-specific Rich styles and a StrEnum of their corresponding names - [cmd2.terminal_utils](./terminal_utils.md) - support for terminal control escape sequences -- [cmd2.transcript](./transcript.md) - functions and classes for running and validating transcripts - [cmd2.utils](./utils.md) - various utility classes and functions diff --git a/docs/api/transcript.md b/docs/api/transcript.md deleted file mode 100644 index bde72d371..000000000 --- a/docs/api/transcript.md +++ /dev/null @@ -1,3 +0,0 @@ -# cmd2.transcript - -::: cmd2.transcript diff --git a/docs/examples/alternate_event_loops.md b/docs/examples/alternate_event_loops.md index 8af0e00c2..0dbe1f01d 100644 --- a/docs/examples/alternate_event_loops.md +++ b/docs/examples/alternate_event_loops.md @@ -78,5 +78,4 @@ integrate with any specific event loop is beyond the scope of this documentation running in this fashion comes with several disadvantages, including: - Requires the developer to write more code -- Does not support transcript testing - Does not allow commands at invocation via command-line arguments diff --git a/docs/examples/getting_started.md b/docs/examples/getting_started.md index fc6dd167d..070158891 100644 --- a/docs/examples/getting_started.md +++ b/docs/examples/getting_started.md @@ -152,7 +152,6 @@ The last thing you'll notice is that we used the `self.poutput()` method to disp 1. Allows the user to redirect output to a text file or pipe it to a shell process 1. Gracefully handles `BrokenPipeError` exceptions for redirected output -1. Makes the output show up in a [transcript](../features/transcripts.md) 1. Honors the setting to [strip embedded ANSI sequences](../features/settings.md#allow_style) (typically used for background and foreground colors) diff --git a/docs/features/history.md b/docs/features/history.md index c6a64fb70..c9ece9765 100644 --- a/docs/features/history.md +++ b/docs/features/history.md @@ -167,20 +167,6 @@ text file: (Cmd) history :5 -o history.txt -The `history` command can also save both the commands and their output to a text file. This is -called a transcript. See [Transcripts](./transcripts.md) for more information on how transcripts -work, and what you can use them for. To create a transcript use the `-t` or `--transcription` -option: - - (Cmd) history 2:3 --transcript transcript.txt - -The `--transcript` option implies `--run`: the commands must be re-run in order to capture their -output to the transcript file. - -!!! warning - - Unlike the `-o`/`--output-file` option, the `-t`/`--transcript` option will actually run the selected history commands again. This is necessary for creating a transcript file since the history saves the commands themselves but does not save their output. Please note that a side-effect of this is that the commands will appear again at the end of the history. - The last action the history command can perform is to clear the command history using `-c` or `--clear`: @@ -189,11 +175,11 @@ The last action the history command can perform is to clear the command history In addition to these five actions, the `history` command also has some options to control how the output is formatted. With no arguments, the `history` command displays the command number before each command. This is great when displaying history to the screen because it gives you an easy -reference to identify previously entered commands. However, when creating a script or a transcript, -the command numbers would prevent the script from loading properly. The `-s` or `--script` option -instructs the `history` command to suppress the line numbers. This option is automatically set by -the `--output-file`, `--transcript`, and `--edit` options. If you want to output the history -commands with line numbers to a file, you can do it with output redirection: +reference to identify previously entered commands. However, when creating a script, the command +numbers would prevent the script from loading properly. The `-s` or `--script` option instructs the +`history` command to suppress the line numbers. This option is automatically set by the +`--output-file` and `--edit` options. If you want to output the history commands with line numbers +to a file, you can do it with output redirection: (Cmd) history 1:4 > history.txt diff --git a/docs/features/hooks.md b/docs/features/hooks.md index 68c692f83..6fa59bb38 100644 --- a/docs/features/hooks.md +++ b/docs/features/hooks.md @@ -74,7 +74,6 @@ loop behavior: - `allow_cli_args` - allows commands to be specified on the operating system command line which are executed before the command processing loop begins -- `transcript_files` - see [Transcripts](./transcripts.md) for more information - `startup_script` - run a script on initialization. See [Scripting](./scripting.md) for more information diff --git a/docs/features/index.md b/docs/features/index.md index 2e7e48827..619626ef3 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -29,6 +29,5 @@ - [Startup Commands](startup_commands.md) - [Table Creation](table_creation.md) - [Theme](theme.md) -- [Transcripts](transcripts.md) diff --git a/docs/features/os.md b/docs/features/os.md index 4dad65b11..114e38dec 100644 --- a/docs/features/os.md +++ b/docs/features/os.md @@ -77,23 +77,23 @@ user to enter commands, which are then executed by your program. You may want to execute commands in your program without prompting the user for any input. There are several ways you might accomplish this task. The easiest one is to pipe commands and their arguments into your program via standard input. You don't need to do anything to your program in order to use -this technique. Here's a demonstration using the `examples/transcript_example.py` included in the +this technique. Here's a demonstration using the `examples/cmd_as_argument.py` included in the source code of `cmd2`: - $ echo "speak -p some words" | python examples/transcript_example.py + $ echo "speak -p some words" | python examples/cmd_as_argument.py omesay ordsway Using this same approach you could create a text file containing the commands you would like to run, one command per line in the file. Say your file was called `somecmds.txt`. To run the commands in the text file using your `cmd2` program (from a Windows command prompt): - c:\cmd2> type somecmds.txt | python.exe examples/transcript_example.py + c:\cmd2> type somecmds.txt | python.exe examples/cmd_as_argument.py omesay ordsway By default, `cmd2` programs also look for commands passed as arguments from the operating system shell, and execute those commands before entering the command loop: - $ python examples/transcript_example.py help + $ python examples/cmd_as_argument.py help Documented Commands ─────────────────── @@ -107,8 +107,8 @@ example, you might have a command inside your `cmd2` program which itself accept maybe even option strings. Say you wanted to run the `speak` command from the operating system shell, but have it say it in pig latin: - $ python examples/transcript_example.py speak -p hello there - python transcript_example.py speak -p hello there + $ python examples/cmd_as_argument.py speak -p hello there + python cmd_as_argument.py speak -p hello there usage: speak [-h] [-p] [-s] [-r REPEAT] words [words ...] speak: error: the following arguments are required: words *** Unknown syntax: -p @@ -131,7 +131,7 @@ Check the source code of this example, especially the `main()` function, to see Alternatively you can simply wrap the command plus arguments in quotes (either single or double quotes): - $ python examples/transcript_example.py "speak -p hello there" + $ python examples/cmd_as_argument.py "speak -p hello there" ellohay heretay (Cmd) @@ -157,6 +157,6 @@ quits while returning an exit code: Here is another example using `quit`: - $ python examples/transcript_example.py "speak -p hello there" quit + $ python examples/cmd_as_argument.py "speak -p hello there" quit ellohay heretay $ diff --git a/docs/features/startup_commands.md b/docs/features/startup_commands.md index 87daf0bc9..7bf65f4dc 100644 --- a/docs/features/startup_commands.md +++ b/docs/features/startup_commands.md @@ -16,7 +16,7 @@ program. `cmd2` interprets each argument as a separate command, so you should en in quotation marks if it is more than a one-word command. You can use either single or double quotes for this purpose. - $ python examples/transcript_example.py "say hello" "say Gracie" quit + $ python examples/cmd_as_argument.py "say hello" "say Gracie" quit hello Gracie diff --git a/docs/features/transcripts.md b/docs/features/transcripts.md deleted file mode 100644 index 4e3f2bca5..000000000 --- a/docs/features/transcripts.md +++ /dev/null @@ -1,182 +0,0 @@ -# Transcripts - -A transcript is both the input and output of a successful session of a `cmd2`-based app which is -saved to a text file. With no extra work on your part, your app can play back these transcripts as a -regression test. Transcripts can contain regular expressions, which provide the flexibility to match -responses from commands that produce dynamic or variable output. - -## Creating From History - -A transcript can be automatically generated based upon commands previously executed in the _history_ -using `history -t`: - -```text -(Cmd) help -... -(Cmd) help history -... -(Cmd) history 1:2 -t transcript.txt -2 commands and outputs saved to transcript file 'transcript.txt' -``` - -This is by far the easiest way to generate a transcript. - -!!! warning - - Make sure you use the **poutput()** method in your `cmd2` application for generating command output. This method of the [cmd2.Cmd][] class ensures that output is properly redirected when redirecting to a file, piping to a shell command, and when generating a transcript. - -## Creating From A Script File - -A transcript can also be automatically generated from a script file using `run_script -t`: - -```text -(Cmd) run_script scripts/script.txt -t transcript.txt -2 commands and their outputs saved to transcript file 'transcript.txt' -(Cmd) -``` - -This is a particularly attractive option for automatically regenerating transcripts for regression -testing as your `cmd2` application changes. - -## Creating Manually - -Here's a transcript created from `python examples/transcript_example.py`: - -```text -(Cmd) say -r 3 Goodnight, Gracie -Goodnight, Gracie -Goodnight, Gracie -Goodnight, Gracie -(Cmd) mumble maybe we could go to lunch -like maybe we ... could go to hmmm lunch -(Cmd) mumble maybe we could go to lunch -well maybe we could like go to er lunch right? -``` - -This transcript has three commands: they are on the lines that begin with the prompt. The first -command looks like this: - -```text -(Cmd) say -r 3 Goodnight, Gracie -``` - -Following each command is the output generated by that command. - -The transcript ignores all lines in the file until it reaches the first line that begins with the -prompt. You can take advantage of this by using the first lines of the transcript as comments: - -```text -# Lines at the beginning of the transcript that do not -; start with the prompt i.e. '(Cmd) ' are ignored. -/* You can use them for comments. */ - -All six of these lines before the first prompt are treated as comments. - -(Cmd) say -r 3 Goodnight, Gracie -Goodnight, Gracie -Goodnight, Gracie -Goodnight, Gracie -(Cmd) mumble maybe we could go to lunch -like maybe we ... could go to hmmm lunch -(Cmd) mumble maybe we could go to lunch -maybe we could like go to er lunch right? -``` - -In this example I've used several different commenting styles, and even bare text. It doesn't matter -what you put on those beginning lines. Everything before: - -```text -(Cmd) say -r 3 Goodnight, Gracie -``` - -will be ignored. - -## Regular Expressions - -If we used the above transcript as-is, it would likely fail. As you can see, the `mumble` command -doesn't always return the same thing: it inserts random words into the input. - -Regular expressions can be included in the response portion of a transcript, and are surrounded by -slashes: - -```text -(Cmd) mumble maybe we could go to lunch -/.*\bmaybe\b.*\bcould\b.*\blunch\b.*/ -(Cmd) mumble maybe we could go to lunch -/.*\bmaybe\b.*\bcould\b.*\blunch\b.*/ -``` - -Without creating a tutorial on regular expressions, this one matches anything that has the words -`maybe`, `could`, and `lunch` in that order. It doesn't ensure that `we` or `go` or `to` appear in -the output, but it does work if mumble happens to add words to the beginning or the end of the -output. - -Since the output could be multiple lines long, `cmd2` uses multiline regular expression matching, -and also uses the `DOTALL` flag. These two flags subtly change the behavior of commonly used special -characters like `.`, `^` and `$`, so you may want to double check the -[Python regular expression documentation](https://docs.python.org/3/library/re.html). - -If your output has slashes in it, you will need to escape those slashes so the stuff between them is -not interpreted as a regular expression. In this transcript: - -```text -(Cmd) say cd /usr/local/lib/python3.11/site-packages -/usr/local/lib/python3.11/site-packages -``` - -the output contains slashes. The text between the first slash and the second slash, will be -interpreted as a regular expression, and those two slashes will not be included in the comparison. -When replayed, this transcript would therefore fail. To fix it, we could either write a regular -expression to match the path instead of specifying it verbatim, or we can escape the slashes: - -```text -(Cmd) say cd /usr/local/lib/python3.11/site-packages -\/usr\/local\/lib\/python3.11\/site-packages -``` - -!!! warning - - Be aware of trailing spaces and newlines. Your commands might output trailing spaces which are impossible to see. Instead of leaving them invisible, you can add a regular expression to match them, so that you can see where they are when you look at the transcript: - - ```text - (Cmd) set editor - editor: vim/ / - ``` - - Some terminal emulators strip trailing space when you copy text from them. This could make the actual data generated by your app different than the text you pasted into the transcript, and it might not be readily obvious why the transcript is not passing. Consider using [redirection](./redirection.md) to the clipboard or to a file to ensure you accurately capture the output of your command. - - If you aren't using regular expressions, make sure the newlines at the end of your transcript exactly match the output of your commands. A common cause of a failing transcript is an extra or missing newline. - - If you are using regular expressions, be aware that depending on how you write your regex, the newlines after the regex may or may not matter. `\Z` matches _after_ the newline at the end of the string, whereas `$` matches the end of the string _or_ just before a newline. - -## Running A Transcript - -Once you have created a transcript, it's easy to have your application play it back and check the -output. From within the `examples/` directory: - -```text -$ python transcript_example.py --test transcript_regex.txt -. ----------------------------------------------------------------------- -Ran 1 test in 0.013s - -OK -``` - -The output will look familiar if you use `unittest`, because that's exactly what happens. Each -command in the transcript is run, and we `assert` the output matches the expected result from the -transcript. - -!!! note - - If you have passed an `allow_cli_args` parameter containing `False` to `cmd2.Cmd.__init__` in order to disable parsing of command line arguments at invocation, then the use of `-t` or `--test` to run transcript testing is automatically disabled. In this case, you can alternatively provide a value for the optional `transcript_files` when constructing the instance of your `cmd2.Cmd` derived class in order to cause a transcript test to run: - - ```py - from cmd2 import Cmd - class App(Cmd): - # customized attributes and methods here - - if __name__ == '__main__': - app = App(transcript_files=['exampleSession.txt']) - app.cmdloop() - ``` diff --git a/docs/migrating/incompatibilities.md b/docs/migrating/incompatibilities.md index df9668c02..7c7f5044a 100644 --- a/docs/migrating/incompatibilities.md +++ b/docs/migrating/incompatibilities.md @@ -38,8 +38,8 @@ new input is needed; if it is nonempty, its elements will be processed in order, the prompt. Since version 0.9.13 `cmd2` has removed support for `Cmd.cmdqueue`. Because `cmd2` supports running -commands via the main `cmdloop()`, text scripts, Python scripts, transcripts, and history replays, -the only way to preserve consistent behavior across these methods was to eliminate the command -queue. Additionally, reasoning about application behavior is much easier without this queue present. +commands via the main `cmdloop()`, text scripts, Python scripts, and history replays, the only way +to preserve consistent behavior across these methods was to eliminate the command queue. +Additionally, reasoning about application behavior is much easier without this queue present. [cmd]: https://docs.python.org/3/library/cmd diff --git a/docs/migrating/why.md b/docs/migrating/why.md index 40301bfad..c0aee99db 100644 --- a/docs/migrating/why.md +++ b/docs/migrating/why.md @@ -52,9 +52,6 @@ capabilities, without you having to do anything: - [Clipboard Integration](../features/clipboard.md) allows you to save command output to the operating system clipboard. - A built-in [Timer](../features/misc.md#Timer) can show how long it takes a command to execute -- A [Transcript](../features/transcripts.md) is a file which contains both the input and output of a - successful session of a `cmd2`-based app. The transcript can be played back into the app as a unit - test. ## Next Steps diff --git a/examples/README.md b/examples/README.md index 060123568..45153c0f7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -93,7 +93,5 @@ each: - Shell script that launches two applications using tmux in different windows/tabs - [tmux_split.sh](https://github.com/python-cmd2/cmd2/blob/main/examples/tmux_split.sh) - Shell script that launches two applications using tmux in a split pane view -- [transcript_example.py](https://github.com/python-cmd2/cmd2/blob/main/examples/transcript_example.py) - - This example is intended to demonstrate `cmd2's` build-in transcript testing capability - [unicode_commands.py](https://github.com/python-cmd2/cmd2/blob/main/examples/unicode_commands.py) - Shows that cmd2 supports unicode everywhere, including within command names diff --git a/examples/cmd_as_argument.py b/examples/cmd_as_argument.py index b9db4acd5..f86b4e90b 100755 --- a/examples/cmd_as_argument.py +++ b/examples/cmd_as_argument.py @@ -1,13 +1,10 @@ #!/usr/bin/env python """A sample application for cmd2. -This example is very similar to transcript_example.py, but had additional -code in main() that shows how to accept a command from +This example has additional code in main() that shows how to accept a command from the command line at invocation: $ python cmd_as_argument.py speak -p hello there - - """ import argparse diff --git a/examples/transcript_example.py b/examples/transcript_example.py deleted file mode 100755 index c6d066f78..000000000 --- a/examples/transcript_example.py +++ /dev/null @@ -1,84 +0,0 @@ -#!/usr/bin/env python -"""A sample application for cmd2. - -Thanks to cmd2's built-in transcript testing capability, it also serves as a -test suite for transcript_example.py when used with the transcripts/transcript_regex.txt transcript. - -Running `python transcript_example.py -t transcripts/transcript_regex.txt` will run all the commands in -the transcript against transcript_example.py, verifying that the output produced matches -the transcript. -""" - -import random - -import cmd2 - - -class CmdLineApp(cmd2.Cmd): - """Example cmd2 application.""" - - # Setting this true makes it run a shell command if a cmd2/cmd command doesn't exist - # default_to_shell = True # noqa: ERA001 - MUMBLES = ('like', '...', 'um', 'er', 'hmmm', 'ahh') - MUMBLE_FIRST = ('so', 'like', 'well') - MUMBLE_LAST = ('right?',) - - def __init__(self) -> None: - shortcuts = cmd2.DEFAULT_SHORTCUTS - shortcuts.update({'&': 'speak'}) - super().__init__(multiline_commands=['orate'], shortcuts=shortcuts) - - # Make maxrepeats settable at runtime - self.maxrepeats = 3 - self.add_settable(cmd2.Settable('maxrepeats', int, 'max repetitions for speak command', self)) - - speak_parser = cmd2.Cmd2ArgumentParser() - speak_parser.add_argument('-p', '--piglatin', action='store_true', help='atinLay') - speak_parser.add_argument('-s', '--shout', action='store_true', help='N00B EMULATION MODE') - speak_parser.add_argument('-r', '--repeat', type=int, help='output [n] times') - speak_parser.add_argument('words', nargs='+', help='words to say') - - @cmd2.with_argparser(speak_parser) - def do_speak(self, args) -> None: - """Repeats what you tell me to.""" - words = [] - for word in args.words: - if args.piglatin: - word = f'{word[1:]}{word[0]}ay' - if args.shout: - word = word.upper() - words.append(word) - repetitions = args.repeat or 1 - for _ in range(min(repetitions, self.maxrepeats)): - # .poutput handles newlines, and accommodates output redirection too - self.poutput(' '.join(words)) - - do_say = do_speak # now "say" is a synonym for "speak" - do_orate = do_speak # another synonym, but this one takes multi-line input - - mumble_parser = cmd2.Cmd2ArgumentParser() - mumble_parser.add_argument('-r', '--repeat', type=int, help='how many times to repeat') - mumble_parser.add_argument('words', nargs='+', help='words to say') - - @cmd2.with_argparser(mumble_parser) - def do_mumble(self, args) -> None: - """Mumbles what you tell me to.""" - repetitions = args.repeat or 1 - for _ in range(min(repetitions, self.maxrepeats)): - output = [] - if random.random() < 0.33: - output.append(random.choice(self.MUMBLE_FIRST)) - for word in args.words: - if random.random() < 0.40: - output.append(random.choice(self.MUMBLES)) - output.append(word) - if random.random() < 0.25: - output.append(random.choice(self.MUMBLE_LAST)) - self.poutput(' '.join(output)) - - -if __name__ == '__main__': - import sys - - c = CmdLineApp() - sys.exit(c.cmdloop()) diff --git a/examples/transcripts/exampleSession.txt b/examples/transcripts/exampleSession.txt deleted file mode 100644 index f420792ce..000000000 --- a/examples/transcripts/exampleSession.txt +++ /dev/null @@ -1,14 +0,0 @@ -# Run this transcript with "python transcript_example.py -t exampleSession.txt" -# Anything between two forward slashes, /, is interpreted as a regular expression (regex). -# The regex for editor will match whatever program you use. -# regexes on prompts just make the trailing space obvious -(Cmd) set -allow_style: '/(Terminal|Always|Never)/' -debug: False -echo: False -editor: /.*?/ -feedback_to_output: False -max_completion_table_items: 50 -maxrepeats: 3 -quiet: False -timing: False diff --git a/examples/transcripts/pirate.transcript b/examples/transcripts/pirate.transcript deleted file mode 100644 index 570f0cd7b..000000000 --- a/examples/transcripts/pirate.transcript +++ /dev/null @@ -1,10 +0,0 @@ -arrr> loot -Now we gots 1 doubloons -arrr> loot -Now we gots 2 doubloons -arrr> loot -Now we gots 3 doubloons -arrr> drink 3 -Now we gots 0 doubloons -arrr> yo --ho 3 rum -yo ho ho ho and a bottle of rum diff --git a/examples/transcripts/quit.txt b/examples/transcripts/quit.txt deleted file mode 100644 index 6dcf8c666..000000000 --- a/examples/transcripts/quit.txt +++ /dev/null @@ -1 +0,0 @@ -(Cmd) quit diff --git a/examples/transcripts/transcript_regex.txt b/examples/transcripts/transcript_regex.txt deleted file mode 100644 index ae428ed6c..000000000 --- a/examples/transcripts/transcript_regex.txt +++ /dev/null @@ -1,19 +0,0 @@ -# Run this transcript with "python transcript_example.py -t transcripts/transcript_regex.txt" -# Anything between two forward slashes, /, is interpreted as a regular expression (regex). -# The regex for editor will match whatever program you use. -# regexes on prompts just make the trailing space obvious -(Cmd) set - - Name Value Description -────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - allow_style Terminal Allow ANSI text style sequences in output (valid values: Always, Never, Terminal) - always_show_hint False Display completion hint even when completion suggestions print - debug True Show full traceback on exception - echo False Echo command issued into output - editor vim Program used by 'edit' - feedback_to_output False Include nonessentials in '|' and '>' results - max_column_completion_results 7 Maximum number of completion results to display in a single column - max_completion_table_items 50 Maximum number of completion results allowed for a completion table to appear - quiet False Don't print nonessential feedback - scripts_add_to_history True Scripts and pyscripts add commands to history - timing False Report execution times diff --git a/mkdocs.yml b/mkdocs.yml index 5d970c9b1..d439bb1a7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -181,7 +181,6 @@ nav: - features/startup_commands.md - features/table_creation.md - features/theme.md - - features/transcripts.md - Examples: - examples/index.md - examples/getting_started.md @@ -212,7 +211,6 @@ nav: - api/string_utils.md - api/styles.md - api/terminal_utils.md - - api/transcript.md - api/utils.md - Version Upgrades: - upgrades.md diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 9725a6372..f9ed0a5fa 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -3538,12 +3538,6 @@ def test_startup_script_with_odd_file_names(startup_script) -> None: os.path.exists = saved_exists -def test_transcripts_at_init() -> None: - transcript_files = ['foo', 'bar'] - app = cmd2.Cmd(allow_cli_args=False, transcript_files=transcript_files) - assert app._transcript_files == transcript_files - - def test_command_parser_retrieval(outsim_app: cmd2.Cmd) -> None: # Pass something that isn't a method not_a_method = "just a string" diff --git a/tests/test_history.py b/tests/test_history.py index 77ec78eca..9791a1204 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -764,7 +764,7 @@ def test_history_clear(mocker, hist_file) -> None: def test_history_verbose_with_other_options(base_app) -> None: # make sure -v shows a usage error if any other options are present - options_to_test = ['-r', '-e', '-o file', '-t file', '-c', '-x'] + options_to_test = ['-r', '-e', '-o file', '-c', '-x'] for opt in options_to_test: out, _err = run_cmd(base_app, 'history -v ' + opt) assert '-v cannot be used with any other options' in out @@ -789,11 +789,11 @@ def test_history_verbose(base_app) -> None: def test_history_script_with_invalid_options(base_app) -> None: - # make sure -s shows a usage error if -c, -r, -e, -o, or -t are present - options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] + # make sure -s shows a usage error if -c, -r, -e, or -o are present + options_to_test = ['-r', '-e', '-o file', '-c'] for opt in options_to_test: out, _err = run_cmd(base_app, 'history -s ' + opt) - assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out + assert '-s and -x cannot be used with -c, -r, -e, or -o' in out assert base_app.last_result is False @@ -807,11 +807,11 @@ def test_history_script(base_app) -> None: def test_history_expanded_with_invalid_options(base_app) -> None: - # make sure -x shows a usage error if -c, -r, -e, -o, or -t are present - options_to_test = ['-r', '-e', '-o file', '-t file', '-c'] + # make sure -x shows a usage error if -c, -r, -e, or -o are present + options_to_test = ['-r', '-e', '-o file', '-c'] for opt in options_to_test: out, _err = run_cmd(base_app, 'history -x ' + opt) - assert '-s and -x cannot be used with -c, -r, -e, -o, or -t' in out + assert '-s and -x cannot be used with -c, -r, -e, or -o' in out assert base_app.last_result is False diff --git a/tests/test_transcript.py b/tests/test_transcript.py deleted file mode 100644 index dc4f91f9d..000000000 --- a/tests/test_transcript.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Cmd2 functional testing based on transcript""" - -import os -import random -import re -import sys -import tempfile -from typing import NoReturn -from unittest import ( - mock, -) - -import pytest - -import cmd2 -from cmd2 import ( - transcript, -) -from cmd2.utils import ( - Settable, - StdSim, -) - -from .conftest import ( - run_cmd, - verify_help_text, -) - - -class CmdLineApp(cmd2.Cmd): - MUMBLES = ('like', '...', 'um', 'er', 'hmmm', 'ahh') - MUMBLE_FIRST = ('so', 'like', 'well') - MUMBLE_LAST = ('right?',) - - def __init__(self, *args, **kwargs) -> None: - self.maxrepeats = 3 - - super().__init__(*args, multiline_commands=['orate'], **kwargs) - - # Make maxrepeats settable at runtime - self.add_settable(Settable('maxrepeats', int, 'Max number of `--repeat`s allowed', self)) - - self.intro = 'This is an intro banner ...' - - speak_parser = cmd2.Cmd2ArgumentParser() - speak_parser.add_argument('-p', '--piglatin', action="store_true", help="atinLay") - speak_parser.add_argument('-s', '--shout', action="store_true", help="N00B EMULATION MODE") - speak_parser.add_argument('-r', '--repeat', type=int, help="output [n] times") - - @cmd2.with_argparser(speak_parser, with_unknown_args=True) - def do_speak(self, opts, arg) -> None: - """Repeats what you tell me to.""" - arg = ' '.join(arg) - if opts.piglatin: - arg = f'{arg[1:]}{arg[0]}ay' - if opts.shout: - arg = arg.upper() - repetitions = opts.repeat or 1 - for _ in range(min(repetitions, self.maxrepeats)): - self.poutput(arg) - # recommend using the poutput function instead of - # self.stdout.write or "print", because Cmd allows the user - # to redirect output - - do_say = do_speak # now "say" is a synonym for "speak" - do_orate = do_speak # another synonym, but this one takes multi-line input - - mumble_parser = cmd2.Cmd2ArgumentParser() - mumble_parser.add_argument('-r', '--repeat', type=int, help="output [n] times") - - @cmd2.with_argparser(mumble_parser, with_unknown_args=True) - def do_mumble(self, opts, arg) -> None: - """Mumbles what you tell me to.""" - repetitions = opts.repeat or 1 - for _ in range(min(repetitions, self.maxrepeats)): - output = [] - if random.random() < 0.33: - output.append(random.choice(self.MUMBLE_FIRST)) - for word in arg: - if random.random() < 0.40: - output.append(random.choice(self.MUMBLES)) - output.append(word) - if random.random() < 0.25: - output.append(random.choice(self.MUMBLE_LAST)) - self.poutput(' '.join(output)) - - def do_nothing(self, statement) -> None: - """Do nothing and output nothing""" - - def do_keyboard_interrupt(self, _) -> NoReturn: - raise KeyboardInterrupt('Interrupting this command') - - -def test_commands_at_invocation() -> None: - testargs = ["prog", "say hello", "say Gracie", "quit"] - expected = "This is an intro banner ...\nhello\nGracie\n" - with mock.patch.object(sys, 'argv', testargs): - app = CmdLineApp() - - app.stdout = StdSim(app.stdout) - app.cmdloop() - out = app.stdout.getvalue() - assert out == expected - - -@pytest.mark.parametrize( - ('filename', 'feedback_to_output'), - [ - ('bol_eol.txt', False), - ('characterclass.txt', False), - ('dotstar.txt', False), - ('extension_notation.txt', False), - ('from_cmdloop.txt', True), - ('multiline_no_regex.txt', False), - ('multiline_regex.txt', False), - ('no_output.txt', False), - ('no_output_last.txt', False), - ('singleslash.txt', False), - ('slashes_escaped.txt', False), - ('slashslash.txt', False), - ('spaces.txt', False), - ('word_boundaries.txt', False), - ], -) -def test_transcript(request, capsys, filename, feedback_to_output) -> None: - # Get location of the transcript - test_dir = os.path.dirname(request.module.__file__) - transcript_file = os.path.join(test_dir, 'transcripts', filename) - - # Need to patch sys.argv so cmd2 doesn't think it was called with - # arguments equal to the py.test args - testargs = ['prog', '-t', transcript_file] - with mock.patch.object(sys, 'argv', testargs): - # Create a cmd2.Cmd() instance and make sure basic settings are - # like we want for test - app = CmdLineApp() - - app.feedback_to_output = feedback_to_output - - # Run the command loop - sys_exit_code = app.cmdloop() - assert sys_exit_code == 0 - - # Check for the unittest "OK" condition for the 1 test which ran - expected_start = ".\n----------------------------------------------------------------------\nRan 1 test in" - expected_end = "s\n\nOK\n" - _, err = capsys.readouterr() - assert err.startswith(expected_start) - assert err.endswith(expected_end) - - -def test_history_transcript() -> None: - app = CmdLineApp() - app.stdout = StdSim(app.stdout) - run_cmd(app, 'orate this is\na /multiline/\ncommand;\n') - run_cmd(app, 'speak /tmp/file.txt is not a regex') - - expected = r"""(Cmd) orate this is -> a /multiline/ -> command; -this is a \/multiline\/ command -(Cmd) speak /tmp/file.txt is not a regex -\/tmp\/file.txt is not a regex -""" - - # make a tmp file - fd, history_fname = tempfile.mkstemp(prefix='', suffix='.txt') - os.close(fd) - - # tell the history command to create a transcript - run_cmd(app, f'history -t "{history_fname}"') - - # read in the transcript created by the history command - with open(history_fname) as f: - xscript = f.read() - - assert xscript == expected - - -def test_history_transcript_bad_path(mocker) -> None: - app = CmdLineApp() - app.stdout = StdSim(app.stdout) - run_cmd(app, 'orate this is\na /multiline/\ncommand;\n') - run_cmd(app, 'speak /tmp/file.txt is not a regex') - - # Bad directory - history_fname = '~/fakedir/this_does_not_exist.txt' - _out, err = run_cmd(app, f'history -t "{history_fname}"') - assert "is not a directory" in err[0] - - # Cause os.open to fail and make sure error gets printed - mock_remove = mocker.patch('builtins.open') - mock_remove.side_effect = OSError - - history_fname = 'outfile.txt' - _out, err = run_cmd(app, f'history -t "{history_fname}"') - assert "Error saving transcript file" in err[0] - - -def test_run_script_record_transcript(base_app, request) -> None: - test_dir = os.path.dirname(request.module.__file__) - filename = os.path.join(test_dir, 'scripts', 'help.txt') - - assert base_app._script_dir == [] - assert base_app._current_script_dir is None - - # make a tmp file to use as a transcript - fd, transcript_fname = tempfile.mkstemp(prefix='', suffix='.trn') - os.close(fd) - - # Execute the run_script command with the -t option to generate a transcript - run_cmd(base_app, f'run_script {filename} -t {transcript_fname}') - - assert base_app._script_dir == [] - assert base_app._current_script_dir is None - - # read in the transcript created by the history command - with open(transcript_fname) as f: - xscript = f.read() - - assert xscript.startswith('(Cmd) help -v\n') - verify_help_text(base_app, xscript) - - -def test_generate_transcript_stop(capsys) -> None: - # Verify transcript generation stops when a command returns True for stop - app = CmdLineApp() - - # Make a tmp file to use as a transcript - fd, transcript_fname = tempfile.mkstemp(prefix='', suffix='.trn') - os.close(fd) - - # This should run all commands - commands = ['help', 'set'] - app._generate_transcript(commands, transcript_fname) - _, err = capsys.readouterr() - assert err.startswith("2 commands") - - # Since quit returns True for stop, only the first 2 commands will run - commands = ['help', 'quit', 'set'] - app._generate_transcript(commands, transcript_fname) - _, err = capsys.readouterr() - assert err.startswith("Command 2 triggered a stop") - - # keyboard_interrupt command should stop the loop and not run the third command - commands = ['help', 'keyboard_interrupt', 'set'] - app._generate_transcript(commands, transcript_fname) - _, err = capsys.readouterr() - assert err.startswith("Interrupting this command\nCommand 2 triggered a stop") - - -@pytest.mark.parametrize( - ('expected', 'transformed'), - [ - # strings with zero or one slash or with escaped slashes means no regular - # expression present, so the result should just be what re.escape returns. - # we don't use static strings in these tests because re.escape behaves - # differently in python 3.7+ than in prior versions - ('text with no slashes', re.escape('text with no slashes')), - ('specials .*', re.escape('specials .*')), - ('use 2/3 cup', re.escape('use 2/3 cup')), - ('/tmp is nice', re.escape('/tmp is nice')), - ('slash at end/', re.escape('slash at end/')), - # escaped slashes - (r'not this slash\/ or this one\/', re.escape('not this slash/ or this one/')), - # regexes - ('/.*/', '.*'), - ('specials ^ and + /[0-9]+/', re.escape('specials ^ and + ') + '[0-9]+'), - (r'/a{6}/ but not \/a{6} with /.*?/ more', 'a{6}' + re.escape(' but not /a{6} with ') + '.*?' + re.escape(' more')), - (r'not \/, use /\|?/, not \/', re.escape('not /, use ') + r'\|?' + re.escape(', not /')), - # inception: slashes in our regex. backslashed on input, bare on output - (r'not \/, use /\/?/, not \/', re.escape('not /, use ') + '/?' + re.escape(', not /')), - (r'lots /\/?/ more /.*/ stuff', re.escape('lots ') + '/?' + re.escape(' more ') + '.*' + re.escape(' stuff')), - ], -) -def test_parse_transcript_expected(expected, transformed) -> None: - app = CmdLineApp() - - class TestMyAppCase(transcript.Cmd2TestCase): - cmdapp = app - - testcase = TestMyAppCase() - assert testcase._transform_transcript_expected(expected) == transformed - - -def test_transcript_failure(request, capsys) -> None: - # Get location of the transcript - test_dir = os.path.dirname(request.module.__file__) - transcript_file = os.path.join(test_dir, 'transcripts', 'failure.txt') - - # Need to patch sys.argv so cmd2 doesn't think it was called with - # arguments equal to the py.test args - testargs = ['prog', '-t', transcript_file] - with mock.patch.object(sys, 'argv', testargs): - # Create a cmd2.Cmd() instance and make sure basic settings are - # like we want for test - app = CmdLineApp() - - app.feedback_to_output = False - - # Run the command loop - sys_exit_code = app.cmdloop() - assert sys_exit_code != 0 - - expected_start = "File " - expected_end = "s\n\nFAILED (failures=1)\n\n" - _, err = capsys.readouterr() - assert err.startswith(expected_start) - assert err.endswith(expected_end) - - -def test_transcript_no_file(request, capsys) -> None: - # Need to patch sys.argv so cmd2 doesn't think it was called with - # arguments equal to the py.test args - testargs = ['prog', '-t'] - with mock.patch.object(sys, 'argv', testargs): - app = CmdLineApp() - - app.feedback_to_output = False - - # Run the command loop - sys_exit_code = app.cmdloop() - assert sys_exit_code != 0 - - # Check for the unittest "OK" condition for the 1 test which ran - expected = 'No test files found - nothing to test\n' - _, err = capsys.readouterr() - assert err == expected diff --git a/tests/transcripts/bol_eol.txt b/tests/transcripts/bol_eol.txt deleted file mode 100644 index da21ac86f..000000000 --- a/tests/transcripts/bol_eol.txt +++ /dev/null @@ -1,6 +0,0 @@ -# match the text with regular expressions and the newlines as literal text - -(Cmd) say -r 3 -s yabba dabba do -/^Y.*?$/ -/^Y.*?$/ -/^Y.*?$/ diff --git a/tests/transcripts/characterclass.txt b/tests/transcripts/characterclass.txt deleted file mode 100644 index 756044ea6..000000000 --- a/tests/transcripts/characterclass.txt +++ /dev/null @@ -1,6 +0,0 @@ -# match using character classes and special sequence for digits (\d) - -(Cmd) say 555-1212 -/[0-9]{3}-[0-9]{4}/ -(Cmd) say 555-1212 -/\d{3}-\d{4}/ diff --git a/tests/transcripts/dotstar.txt b/tests/transcripts/dotstar.txt deleted file mode 100644 index 55c15b759..000000000 --- a/tests/transcripts/dotstar.txt +++ /dev/null @@ -1,4 +0,0 @@ -# ensure the old standby .* works. We use the non-greedy flavor - -(Cmd) say Adopt the pace of nature: her secret is patience. -Adopt the pace of /.*?/ is patience. diff --git a/tests/transcripts/extension_notation.txt b/tests/transcripts/extension_notation.txt deleted file mode 100644 index 68e728ca3..000000000 --- a/tests/transcripts/extension_notation.txt +++ /dev/null @@ -1,4 +0,0 @@ -# inception: a regular expression that matches itself - -(Cmd) say (?:fred) -/(?:\(\?:fred\))/ diff --git a/tests/transcripts/failure.txt b/tests/transcripts/failure.txt deleted file mode 100644 index 4ef56e722..000000000 --- a/tests/transcripts/failure.txt +++ /dev/null @@ -1,4 +0,0 @@ -# This is an example of a transcript test which will fail - -(Cmd) say -r 3 -s yabba dabba do -foo bar baz diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt deleted file mode 100644 index 613a46d35..000000000 --- a/tests/transcripts/from_cmdloop.txt +++ /dev/null @@ -1,44 +0,0 @@ -# responses with trailing spaces have been matched with a regex -# so you can see where they are. - -(Cmd) help say -Usage: speak [-h] [-p] [-s] [-r REPEAT]/ */ - -Repeats what you tell me to./ */ - -Options:/ */ - -h, --help/ */show this help message and exit/ */ - -p, --piglatin/ */atinLay/ */ - -s, --shout/ */N00B EMULATION MODE/ */ - -r, --repeat REPEAT/ */output [n] times/ */ - -(Cmd) say goodnight, Gracie -goodnight, Gracie -(Cmd) say -ps --repeat=5 goodnight, Gracie -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -(Cmd) set maxrepeats 5 -maxrepeats - was: 3 -now: 5 -(Cmd) say -ps --repeat=5 goodnight, Gracie -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -(Cmd) history - 1 help say - 2 say goodnight, Gracie - 3 say -ps --repeat=5 goodnight, Gracie - 4 set maxrepeats 5 - 5 say -ps --repeat=5 goodnight, Gracie -(Cmd) history -r 3 -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -OODNIGHT, GRACIEGAY -(Cmd) set debug True -debug - was: False/ */ -now: True/ */ diff --git a/tests/transcripts/multiline_no_regex.txt b/tests/transcripts/multiline_no_regex.txt deleted file mode 100644 index 490870cf1..000000000 --- a/tests/transcripts/multiline_no_regex.txt +++ /dev/null @@ -1,6 +0,0 @@ -# test a multi-line command - -(Cmd) orate This is a test -> of the -> emergency broadcast system -This is a test of the emergency broadcast system diff --git a/tests/transcripts/multiline_regex.txt b/tests/transcripts/multiline_regex.txt deleted file mode 100644 index 3487335ff..000000000 --- a/tests/transcripts/multiline_regex.txt +++ /dev/null @@ -1,6 +0,0 @@ -# these regular expressions match multiple lines of text - -(Cmd) say -r 3 -s yabba dabba do -/\A(YA.*?DO\n?){3}/ -(Cmd) say -r 5 -s yabba dabba do -/\A([A-Z\s]*$){3}/ diff --git a/tests/transcripts/no_output.txt b/tests/transcripts/no_output.txt deleted file mode 100644 index 6b84e8e76..000000000 --- a/tests/transcripts/no_output.txt +++ /dev/null @@ -1,7 +0,0 @@ -# ensure the transcript can play a command with no output from a command somewhere in the middle - -(Cmd) say something -something -(Cmd) nothing -(Cmd) say something else -something else diff --git a/tests/transcripts/no_output_last.txt b/tests/transcripts/no_output_last.txt deleted file mode 100644 index c75d7e7fe..000000000 --- a/tests/transcripts/no_output_last.txt +++ /dev/null @@ -1,7 +0,0 @@ -# ensure the transcript can play a command with no output from the last command - -(Cmd) say something -something -(Cmd) say something else -something else -(Cmd) nothing diff --git a/tests/transcripts/singleslash.txt b/tests/transcripts/singleslash.txt deleted file mode 100644 index f3b291f91..000000000 --- a/tests/transcripts/singleslash.txt +++ /dev/null @@ -1,5 +0,0 @@ -# even if you only have a single slash, you have -# to escape it - -(Cmd) say use 2/3 cup of sugar -use 2\/3 cup of sugar diff --git a/tests/transcripts/slashes_escaped.txt b/tests/transcripts/slashes_escaped.txt deleted file mode 100644 index 09bbe3bb2..000000000 --- a/tests/transcripts/slashes_escaped.txt +++ /dev/null @@ -1,6 +0,0 @@ -# escape those slashes - -(Cmd) say /some/unix/path -\/some\/unix\/path -(Cmd) say mix 2/3 c. sugar, 1/2 c. butter, and 1/2 tsp. salt -mix 2\/3 c. sugar, 1\/2 c. butter, and 1\/2 tsp. salt diff --git a/tests/transcripts/slashslash.txt b/tests/transcripts/slashslash.txt deleted file mode 100644 index 2504b0baa..000000000 --- a/tests/transcripts/slashslash.txt +++ /dev/null @@ -1,4 +0,0 @@ -# ensure consecutive slashes are parsed correctly - -(Cmd) say // -\/\/ diff --git a/tests/transcripts/spaces.txt b/tests/transcripts/spaces.txt deleted file mode 100644 index 615fcbd7f..000000000 --- a/tests/transcripts/spaces.txt +++ /dev/null @@ -1,8 +0,0 @@ -# check spaces in all their forms - -(Cmd) say how many spaces -how many spaces -(Cmd) say how many spaces -how/\s{1}/many/\s{1}/spaces -(Cmd) say "how many spaces" -how/\s+/many/\s+/spaces diff --git a/tests/transcripts/word_boundaries.txt b/tests/transcripts/word_boundaries.txt deleted file mode 100644 index e79cfc4fc..000000000 --- a/tests/transcripts/word_boundaries.txt +++ /dev/null @@ -1,6 +0,0 @@ -# use word boundaries to check for key words in the output - -(Cmd) mumble maybe we could go to lunch -/.*\bmaybe\b.*\bcould\b.*\blunch\b.*/ -(Cmd) mumble maybe we could go to lunch -/.*\bmaybe\b.*\bcould\b.*\blunch\b.*/