diff --git a/CHANGELOG.md b/CHANGELOG.md index f5891b12..ce9a7102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 4.0.0 (TBD, 2026) + +- Potentially Breaking Changes + - `cmd2` no longer has a dependency on `cmd` and `cmd2.Cmd` no longer inherits from `cmd.Cmd` + - We don't _think_ this should impact users, but there is theoretically a possibility + ## 3.0.0 (December 7, 2025) ### Summary diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 0a9e3a3b..e5c1bc55 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -1,24 +1,26 @@ -"""Variant on standard library's cmd with extra features. - -To use, simply import cmd2.Cmd instead of cmd.Cmd; use precisely as though you -were using the standard library's cmd, while enjoying the extra features. - -Searchable command history (commands: "history") -Run commands from file, save to file, edit commands in file -Multi-line commands -Special-character shortcut commands (beyond cmd's "?" and "!") -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 +"""cmd2 - quickly build feature-rich and user-friendly interactive command line applications in Python. + +cmd2 is a tool for building interactive command line applications in Python. Its goal is to make it quick and easy for +developers to build feature-rich and user-friendly interactive command line applications. It provides a simple API which +is an extension of Python's built-in cmd module. cmd2 provides a wealth of features on top of cmd to make your life easier +and eliminates much of the boilerplate code which would be necessary when using cmd. + +Extra features include: +- Searchable command history (commands: "history") +- Run commands from file, save to file, edit commands in file +- Multi-line commands +- Special-character shortcut commands (beyond cmd's "?" and "!") +- 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 | will only work if `self.poutput()` is used in place of `print`. -- Catherine Devlin, Jan 03 2008 - catherinedevlin.blogspot.com - -Git repository on GitHub at https://github.com/python-cmd2/cmd2 +GitHub: https://github.com/python-cmd2/cmd2 +Documentation: https://cmd2.readthedocs.io/ """ # This module has many imports, quite a few of which are only @@ -26,7 +28,6 @@ # import this module, many of these imports are lazy-loaded # i.e. we only import the module when we use it. import argparse -import cmd import contextlib import copy import functools @@ -64,7 +65,7 @@ ) import rich.box -from rich.console import Group +from rich.console import Group, RenderableType from rich.highlighter import ReprHighlighter from rich.rule import Rule from rich.style import Style, StyleType @@ -286,7 +287,7 @@ def remove(self, command_method: CommandFunc) -> None: del self._parsers[full_method_name] -class Cmd(cmd.Cmd): +class Cmd: """An easy but powerful framework for writing line-oriented command interpreters. Extends the Python Standard Library's cmd package by adding a lot of useful features @@ -304,6 +305,8 @@ class Cmd(cmd.Cmd): # List for storing transcript test file names testfiles: ClassVar[list[str]] = [] + DEFAULT_PROMPT = '(Cmd) ' + def __init__( self, completekey: str = 'tab', @@ -326,6 +329,7 @@ def __init__( auto_load_commands: bool = False, allow_clipboard: bool = True, suggest_similar_command: bool = False, + intro: RenderableType = '', ) -> None: """Easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package. @@ -376,6 +380,7 @@ def __init__( :param suggest_similar_command: If ``True``, ``cmd2`` will attempt to suggest the most similar command when the user types a command that does not exist. Default: ``False``. + "param intro: Intro banner to print when starting the application. """ # Check if py or ipy need to be disabled in this instance if not include_py: @@ -384,11 +389,28 @@ def __init__( setattr(self, 'do_ipy', None) # noqa: B010 # initialize plugin system - # needs to be done before we call __init__(0) + # needs to be done before we most of the other stuff below self._initialize_plugin_system() - # Call super class constructor - super().__init__(completekey=completekey, stdin=stdin, stdout=stdout) + # Configure a few defaults + self.prompt = Cmd.DEFAULT_PROMPT + self.intro = intro + self.use_rawinput = True + + # What to use for standard input + if stdin is not None: + self.stdin = stdin + else: + self.stdin = sys.stdin + + # What to use for standard output + if stdout is not None: + self.stdout = stdout + else: + self.stdout = sys.stdout + + # Key used for tab completion + self.completekey = completekey # Attributes which should NOT be dynamically settable via the set command at runtime self.default_to_shell = False # Attempt to run unrecognized commands as shell commands @@ -2693,10 +2715,6 @@ def postloop(self) -> None: def parseline(self, line: str) -> tuple[str, str, str]: """Parse the line into a command name and a string containing the arguments. - NOTE: This is an override of a parent class method. It is only used by other parent class methods. - - Different from the parent class method, this ignores self.identchars. - :param line: line read by readline :return: tuple containing (command, args, line) """ @@ -3086,7 +3104,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: # Initialize the redirection saved state redir_saved_state = utils.RedirectionSavedState( - cast(TextIO, self.stdout), stdouts_match, self._cur_pipe_proc_reader, self._redirecting + self.stdout, stdouts_match, self._cur_pipe_proc_reader, self._redirecting ) # The ProcReader for this command @@ -3141,7 +3159,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState: new_stdout.close() raise RedirectionError(f'Pipe process exited with code {proc.returncode} before command could run') redir_saved_state.redirecting = True - cmd_pipe_proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) + cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr) self.stdout = new_stdout if stdouts_match: @@ -3293,6 +3311,15 @@ def default(self, statement: Statement) -> bool | None: # type: ignore[override self.perror(err_msg, style=None) return None + def completedefault(self, *_ignored: list[str]) -> list[str]: + """Call to complete an input line when no command-specific complete_*() method is available. + + This method is only called for non-argparse-based commands. + + By default, it returns an empty list. + """ + return [] + def _suggest_similar_command(self, command: str) -> str | None: return suggest_similar(command, self.get_visible_commands()) @@ -4131,10 +4158,6 @@ def _build_help_parser(cls) -> Cmd2ArgumentParser: ) return help_parser - # Get rid of cmd's complete_help() functions so ArgparseCompleter will complete the help command - if getattr(cmd.Cmd, 'complete_help', None) is not None: - delattr(cmd.Cmd, 'complete_help') - @with_argparser(_build_help_parser) def do_help(self, args: argparse.Namespace) -> None: """List available commands or provide detailed help for a specific command.""" @@ -4640,7 +4663,7 @@ def do_shell(self, args: argparse.Namespace) -> None: **kwargs, ) - proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr) + proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr) proc_reader.wait() # Save the return code of the application for use in a pyscript @@ -5359,7 +5382,7 @@ def _generate_transcript( transcript += command # Use a StdSim object to capture output - stdsim = utils.StdSim(cast(TextIO, self.stdout)) + stdsim = utils.StdSim(self.stdout) self.stdout = cast(TextIO, stdsim) # then run the command and let the output go into our buffer @@ -5385,7 +5408,7 @@ def _generate_transcript( with self.sigint_protection: # Restore altered attributes to their original state self.echo = saved_echo - self.stdout = cast(TextIO, saved_stdout) + self.stdout = saved_stdout # Check if all commands ran if commands_run < len(history): @@ -5880,7 +5903,7 @@ def _report_disabled_command_usage(self, *_args: Any, message_to_print: str, **_ """ self.perror(message_to_print, style=None) - def cmdloop(self, intro: str | None = None) -> int: # type: ignore[override] + def cmdloop(self, intro: str = '') -> int: # type: ignore[override] """Deal with extra features provided by cmd2, this is an outer wrapper around _cmdloop(). _cmdloop() provides the main loop equivalent to cmd.cmdloop(). This is a wrapper around that which deals with @@ -5922,11 +5945,11 @@ def cmdloop(self, intro: str | None = None) -> int: # type: ignore[override] 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 is not None: + if intro: self.intro = intro # Print the intro, if there is one, right after the preloop - if self.intro is not None: + if self.intro: self.poutput(self.intro) # And then call _cmdloop() to enter the main loop diff --git a/cmd2/py_bridge.py b/cmd2/py_bridge.py index 3cd08494..56ea2253 100644 --- a/cmd2/py_bridge.py +++ b/cmd2/py_bridge.py @@ -137,7 +137,7 @@ def __call__(self, command: str, *, echo: bool | None = None) -> CommandResult: ) finally: with self._cmd2_app.sigint_protection: - self._cmd2_app.stdout = cast(IO[str], copy_cmd_stdout.inner_stream) + self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout.inner_stream) if stdouts_match: sys.stdout = self._cmd2_app.stdout diff --git a/cmd2/transcript.py b/cmd2/transcript.py index 430ad8ce..6cc90076 100644 --- a/cmd2/transcript.py +++ b/cmd2/transcript.py @@ -46,7 +46,7 @@ def setUp(self) -> None: # Trap stdout self._orig_stdout = self.cmdapp.stdout - self.cmdapp.stdout = cast(TextIO, utils.StdSim(cast(TextIO, 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.""" diff --git a/docs/migrating/why.md b/docs/migrating/why.md index f8f2ff6d..c73e8ae6 100644 --- a/docs/migrating/why.md +++ b/docs/migrating/why.md @@ -22,6 +22,11 @@ of [cmd][cmd] will add many features to an application without any further modif to `cmd2` will also open many additional doors for making it possible for developers to provide a top-notch interactive command-line experience for their users. +!!! warning + + As of version 4.0.0, `cmd2` does not have an actual dependency on `cmd`. `cmd2` is mostly API compatible with `cmd2`. + See [Incompatibilities](./incompatibilities.md) for the few documented incompatibilities. + ## Automatic Features After switching from [cmd][cmd] to `cmd2`, your application will have the following new features and diff --git a/mkdocs.yml b/mkdocs.yml index 5a430416..1ff95631 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,7 +78,6 @@ plugins: show_if_no_docstring: true preload_modules: - argparse - - cmd inherited_members: true members_order: source separate_signature: true diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 070046b1..a4601890 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2106,6 +2106,21 @@ def make_app(isatty: bool, empty_input: bool = False): assert not out +def test_custom_stdout() -> None: + # Create a custom file-like object (e.g., an in-memory string buffer) + custom_output = io.StringIO() + + # Instantiate cmd2.Cmd with the custom_output as stdout + my_app = cmd2.Cmd(stdout=custom_output) + + # Simulate a command + my_app.onecmd('help') + + # Retrieve the output from the custom_output buffer + captured_output = custom_output.getvalue() + assert 'history' in captured_output + + def test_read_command_line_eof(base_app, monkeypatch) -> None: read_input_mock = mock.MagicMock(name='read_input', side_effect=EOFError) monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock) diff --git a/tests/test_completion.py b/tests/test_completion.py index f3994286..bd31bd3f 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -219,14 +219,6 @@ def cmd2_app(): return CompletionsExample() -def test_cmd2_command_completion_single(cmd2_app) -> None: - text = 'he' - line = text - endidx = len(line) - begidx = endidx - len(text) - assert cmd2_app.completenames(text, line, begidx, endidx) == ['help'] - - def test_complete_command_single(cmd2_app) -> None: text = 'he' line = text @@ -322,7 +314,10 @@ def test_cmd2_command_completion_multiple(cmd2_app) -> None: line = text endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.completenames(text, line, begidx, endidx) == ['help', 'history'] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is not None + assert cmd2_app.completion_matches == ['help', 'history'] def test_cmd2_command_completion_nomatch(cmd2_app) -> None: @@ -330,7 +325,10 @@ def test_cmd2_command_completion_nomatch(cmd2_app) -> None: line = text endidx = len(line) begidx = endidx - len(text) - assert cmd2_app.completenames(text, line, begidx, endidx) == [] + + first_match = complete_tester(text, line, begidx, endidx, cmd2_app) + assert first_match is None + assert cmd2_app.completion_matches == [] def test_cmd2_help_completion_single(cmd2_app) -> None: