Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
101 changes: 62 additions & 39 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,33 @@
"""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
# infrequently utilized. To reduce the initial overhead of
# 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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',
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd2/py_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion cmd2/transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
5 changes: 5 additions & 0 deletions docs/migrating/why.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ plugins:
show_if_no_docstring: true
preload_modules:
- argparse
- cmd
inherited_members: true
members_order: source
separate_signature: true
Expand Down
15 changes: 15 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
18 changes: 8 additions & 10 deletions tests/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -322,15 +314,21 @@ 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:
text = 'fakecommand'
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:
Expand Down
Loading