Skip to content

Commit 3e1ec82

Browse files
Remove dependency on Python's built-in cmd module (#1539)
* Remove dependency on Python's built-in cmd module The cmd2.Cmd class no longer inhertis from cmd.Cmd * Address PR comments * Add missing unit test for custom stdout * Deleted an unused method and updated a method comment * Updated command completion tests to use complete_tester(). --------- Co-authored-by: Kevin Van Brunt <kmvanbrunt@gmail.com>
1 parent 35af636 commit 3e1ec82

File tree

8 files changed

+98
-52
lines changed

8 files changed

+98
-52
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
## 4.0.0 (TBD, 2026)
2+
3+
- Potentially Breaking Changes
4+
- `cmd2` no longer has a dependency on `cmd` and `cmd2.Cmd` no longer inherits from `cmd.Cmd`
5+
- We don't _think_ this should impact users, but there is theoretically a possibility
6+
17
## 3.0.0 (December 7, 2025)
28

39
### Summary

cmd2/cmd2.py

Lines changed: 62 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
1-
"""Variant on standard library's cmd with extra features.
2-
3-
To use, simply import cmd2.Cmd instead of cmd.Cmd; use precisely as though you
4-
were using the standard library's cmd, while enjoying the extra features.
5-
6-
Searchable command history (commands: "history")
7-
Run commands from file, save to file, edit commands in file
8-
Multi-line commands
9-
Special-character shortcut commands (beyond cmd's "?" and "!")
10-
Settable environment parameters
11-
Parsing commands with `argparse` argument parsers (flags)
12-
Redirection to file or paste buffer (clipboard) with > or >>
13-
Easy transcript-based testing of applications (see examples/transcript_example.py)
14-
Bash-style ``select`` available
1+
"""cmd2 - quickly build feature-rich and user-friendly interactive command line applications in Python.
2+
3+
cmd2 is a tool for building interactive command line applications in Python. Its goal is to make it quick and easy for
4+
developers to build feature-rich and user-friendly interactive command line applications. It provides a simple API which
5+
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
6+
and eliminates much of the boilerplate code which would be necessary when using cmd.
7+
8+
Extra features include:
9+
- Searchable command history (commands: "history")
10+
- Run commands from file, save to file, edit commands in file
11+
- Multi-line commands
12+
- Special-character shortcut commands (beyond cmd's "?" and "!")
13+
- Settable environment parameters
14+
- Parsing commands with `argparse` argument parsers (flags)
15+
- Redirection to file or paste buffer (clipboard) with > or >>
16+
- Easy transcript-based testing of applications (see examples/transcript_example.py)
17+
- Bash-style ``select`` available
1518
1619
Note, if self.stdout is different than sys.stdout, then redirection with > and |
1720
will only work if `self.poutput()` is used in place of `print`.
1821
19-
- Catherine Devlin, Jan 03 2008 - catherinedevlin.blogspot.com
20-
21-
Git repository on GitHub at https://github.com/python-cmd2/cmd2
22+
GitHub: https://github.com/python-cmd2/cmd2
23+
Documentation: https://cmd2.readthedocs.io/
2224
"""
2325

2426
# This module has many imports, quite a few of which are only
2527
# infrequently utilized. To reduce the initial overhead of
2628
# import this module, many of these imports are lazy-loaded
2729
# i.e. we only import the module when we use it.
2830
import argparse
29-
import cmd
3031
import contextlib
3132
import copy
3233
import functools
@@ -64,7 +65,7 @@
6465
)
6566

6667
import rich.box
67-
from rich.console import Group
68+
from rich.console import Group, RenderableType
6869
from rich.highlighter import ReprHighlighter
6970
from rich.rule import Rule
7071
from rich.style import Style, StyleType
@@ -286,7 +287,7 @@ def remove(self, command_method: CommandFunc) -> None:
286287
del self._parsers[full_method_name]
287288

288289

289-
class Cmd(cmd.Cmd):
290+
class Cmd:
290291
"""An easy but powerful framework for writing line-oriented command interpreters.
291292
292293
Extends the Python Standard Library's cmd package by adding a lot of useful features
@@ -304,6 +305,8 @@ class Cmd(cmd.Cmd):
304305
# List for storing transcript test file names
305306
testfiles: ClassVar[list[str]] = []
306307

308+
DEFAULT_PROMPT = '(Cmd) '
309+
307310
def __init__(
308311
self,
309312
completekey: str = 'tab',
@@ -326,6 +329,7 @@ def __init__(
326329
auto_load_commands: bool = False,
327330
allow_clipboard: bool = True,
328331
suggest_similar_command: bool = False,
332+
intro: RenderableType = '',
329333
) -> None:
330334
"""Easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package.
331335
@@ -376,6 +380,7 @@ def __init__(
376380
:param suggest_similar_command: If ``True``, ``cmd2`` will attempt to suggest the most
377381
similar command when the user types a command that does
378382
not exist. Default: ``False``.
383+
"param intro: Intro banner to print when starting the application.
379384
"""
380385
# Check if py or ipy need to be disabled in this instance
381386
if not include_py:
@@ -384,11 +389,28 @@ def __init__(
384389
setattr(self, 'do_ipy', None) # noqa: B010
385390

386391
# initialize plugin system
387-
# needs to be done before we call __init__(0)
392+
# needs to be done before we most of the other stuff below
388393
self._initialize_plugin_system()
389394

390-
# Call super class constructor
391-
super().__init__(completekey=completekey, stdin=stdin, stdout=stdout)
395+
# Configure a few defaults
396+
self.prompt = Cmd.DEFAULT_PROMPT
397+
self.intro = intro
398+
self.use_rawinput = True
399+
400+
# What to use for standard input
401+
if stdin is not None:
402+
self.stdin = stdin
403+
else:
404+
self.stdin = sys.stdin
405+
406+
# What to use for standard output
407+
if stdout is not None:
408+
self.stdout = stdout
409+
else:
410+
self.stdout = sys.stdout
411+
412+
# Key used for tab completion
413+
self.completekey = completekey
392414

393415
# Attributes which should NOT be dynamically settable via the set command at runtime
394416
self.default_to_shell = False # Attempt to run unrecognized commands as shell commands
@@ -2693,10 +2715,6 @@ def postloop(self) -> None:
26932715
def parseline(self, line: str) -> tuple[str, str, str]:
26942716
"""Parse the line into a command name and a string containing the arguments.
26952717
2696-
NOTE: This is an override of a parent class method. It is only used by other parent class methods.
2697-
2698-
Different from the parent class method, this ignores self.identchars.
2699-
27002718
:param line: line read by readline
27012719
:return: tuple containing (command, args, line)
27022720
"""
@@ -3086,7 +3104,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
30863104

30873105
# Initialize the redirection saved state
30883106
redir_saved_state = utils.RedirectionSavedState(
3089-
cast(TextIO, self.stdout), stdouts_match, self._cur_pipe_proc_reader, self._redirecting
3107+
self.stdout, stdouts_match, self._cur_pipe_proc_reader, self._redirecting
30903108
)
30913109

30923110
# The ProcReader for this command
@@ -3141,7 +3159,7 @@ def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
31413159
new_stdout.close()
31423160
raise RedirectionError(f'Pipe process exited with code {proc.returncode} before command could run')
31433161
redir_saved_state.redirecting = True
3144-
cmd_pipe_proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr)
3162+
cmd_pipe_proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
31453163

31463164
self.stdout = new_stdout
31473165
if stdouts_match:
@@ -3293,6 +3311,15 @@ def default(self, statement: Statement) -> bool | None: # type: ignore[override
32933311
self.perror(err_msg, style=None)
32943312
return None
32953313

3314+
def completedefault(self, *_ignored: list[str]) -> list[str]:
3315+
"""Call to complete an input line when no command-specific complete_*() method is available.
3316+
3317+
This method is only called for non-argparse-based commands.
3318+
3319+
By default, it returns an empty list.
3320+
"""
3321+
return []
3322+
32963323
def _suggest_similar_command(self, command: str) -> str | None:
32973324
return suggest_similar(command, self.get_visible_commands())
32983325

@@ -4131,10 +4158,6 @@ def _build_help_parser(cls) -> Cmd2ArgumentParser:
41314158
)
41324159
return help_parser
41334160

4134-
# Get rid of cmd's complete_help() functions so ArgparseCompleter will complete the help command
4135-
if getattr(cmd.Cmd, 'complete_help', None) is not None:
4136-
delattr(cmd.Cmd, 'complete_help')
4137-
41384161
@with_argparser(_build_help_parser)
41394162
def do_help(self, args: argparse.Namespace) -> None:
41404163
"""List available commands or provide detailed help for a specific command."""
@@ -4640,7 +4663,7 @@ def do_shell(self, args: argparse.Namespace) -> None:
46404663
**kwargs,
46414664
)
46424665

4643-
proc_reader = utils.ProcReader(proc, cast(TextIO, self.stdout), sys.stderr)
4666+
proc_reader = utils.ProcReader(proc, self.stdout, sys.stderr)
46444667
proc_reader.wait()
46454668

46464669
# Save the return code of the application for use in a pyscript
@@ -5359,7 +5382,7 @@ def _generate_transcript(
53595382
transcript += command
53605383

53615384
# Use a StdSim object to capture output
5362-
stdsim = utils.StdSim(cast(TextIO, self.stdout))
5385+
stdsim = utils.StdSim(self.stdout)
53635386
self.stdout = cast(TextIO, stdsim)
53645387

53655388
# then run the command and let the output go into our buffer
@@ -5385,7 +5408,7 @@ def _generate_transcript(
53855408
with self.sigint_protection:
53865409
# Restore altered attributes to their original state
53875410
self.echo = saved_echo
5388-
self.stdout = cast(TextIO, saved_stdout)
5411+
self.stdout = saved_stdout
53895412

53905413
# Check if all commands ran
53915414
if commands_run < len(history):
@@ -5880,7 +5903,7 @@ def _report_disabled_command_usage(self, *_args: Any, message_to_print: str, **_
58805903
"""
58815904
self.perror(message_to_print, style=None)
58825905

5883-
def cmdloop(self, intro: str | None = None) -> int: # type: ignore[override]
5906+
def cmdloop(self, intro: str = '') -> int: # type: ignore[override]
58845907
"""Deal with extra features provided by cmd2, this is an outer wrapper around _cmdloop().
58855908
58865909
_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]
59225945
self._run_transcript_tests([os.path.expanduser(tf) for tf in self._transcript_files])
59235946
else:
59245947
# If an intro was supplied in the method call, allow it to override the default
5925-
if intro is not None:
5948+
if intro:
59265949
self.intro = intro
59275950

59285951
# Print the intro, if there is one, right after the preloop
5929-
if self.intro is not None:
5952+
if self.intro:
59305953
self.poutput(self.intro)
59315954

59325955
# And then call _cmdloop() to enter the main loop

cmd2/py_bridge.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def __call__(self, command: str, *, echo: bool | None = None) -> CommandResult:
137137
)
138138
finally:
139139
with self._cmd2_app.sigint_protection:
140-
self._cmd2_app.stdout = cast(IO[str], copy_cmd_stdout.inner_stream)
140+
self._cmd2_app.stdout = cast(TextIO, copy_cmd_stdout.inner_stream)
141141
if stdouts_match:
142142
sys.stdout = self._cmd2_app.stdout
143143

cmd2/transcript.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def setUp(self) -> None:
4646

4747
# Trap stdout
4848
self._orig_stdout = self.cmdapp.stdout
49-
self.cmdapp.stdout = cast(TextIO, utils.StdSim(cast(TextIO, self.cmdapp.stdout)))
49+
self.cmdapp.stdout = cast(TextIO, utils.StdSim(self.cmdapp.stdout))
5050

5151
def tearDown(self) -> None:
5252
"""Instructions that will be executed after each test method."""

docs/migrating/why.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ of [cmd][cmd] will add many features to an application without any further modif
2222
to `cmd2` will also open many additional doors for making it possible for developers to provide a
2323
top-notch interactive command-line experience for their users.
2424

25+
!!! warning
26+
27+
As of version 4.0.0, `cmd2` does not have an actual dependency on `cmd`. `cmd2` is mostly API compatible with `cmd2`.
28+
See [Incompatibilities](./incompatibilities.md) for the few documented incompatibilities.
29+
2530
## Automatic Features
2631

2732
After switching from [cmd][cmd] to `cmd2`, your application will have the following new features and

mkdocs.yml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,6 @@ plugins:
7878
show_if_no_docstring: true
7979
preload_modules:
8080
- argparse
81-
- cmd
8281
inherited_members: true
8382
members_order: source
8483
separate_signature: true

tests/test_cmd2.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2106,6 +2106,21 @@ def make_app(isatty: bool, empty_input: bool = False):
21062106
assert not out
21072107

21082108

2109+
def test_custom_stdout() -> None:
2110+
# Create a custom file-like object (e.g., an in-memory string buffer)
2111+
custom_output = io.StringIO()
2112+
2113+
# Instantiate cmd2.Cmd with the custom_output as stdout
2114+
my_app = cmd2.Cmd(stdout=custom_output)
2115+
2116+
# Simulate a command
2117+
my_app.onecmd('help')
2118+
2119+
# Retrieve the output from the custom_output buffer
2120+
captured_output = custom_output.getvalue()
2121+
assert 'history' in captured_output
2122+
2123+
21092124
def test_read_command_line_eof(base_app, monkeypatch) -> None:
21102125
read_input_mock = mock.MagicMock(name='read_input', side_effect=EOFError)
21112126
monkeypatch.setattr("cmd2.Cmd.read_input", read_input_mock)

tests/test_completion.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -219,14 +219,6 @@ def cmd2_app():
219219
return CompletionsExample()
220220

221221

222-
def test_cmd2_command_completion_single(cmd2_app) -> None:
223-
text = 'he'
224-
line = text
225-
endidx = len(line)
226-
begidx = endidx - len(text)
227-
assert cmd2_app.completenames(text, line, begidx, endidx) == ['help']
228-
229-
230222
def test_complete_command_single(cmd2_app) -> None:
231223
text = 'he'
232224
line = text
@@ -322,15 +314,21 @@ def test_cmd2_command_completion_multiple(cmd2_app) -> None:
322314
line = text
323315
endidx = len(line)
324316
begidx = endidx - len(text)
325-
assert cmd2_app.completenames(text, line, begidx, endidx) == ['help', 'history']
317+
318+
first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
319+
assert first_match is not None
320+
assert cmd2_app.completion_matches == ['help', 'history']
326321

327322

328323
def test_cmd2_command_completion_nomatch(cmd2_app) -> None:
329324
text = 'fakecommand'
330325
line = text
331326
endidx = len(line)
332327
begidx = endidx - len(text)
333-
assert cmd2_app.completenames(text, line, begidx, endidx) == []
328+
329+
first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
330+
assert first_match is None
331+
assert cmd2_app.completion_matches == []
334332

335333

336334
def test_cmd2_help_completion_single(cmd2_app) -> None:

0 commit comments

Comments
 (0)