Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
- Bug Fixes
- Fixed `@with_annotated` command `--help` output bypassing the application's output stream,
leaving redirected help files empty and preventing help from being captured by embedded apps.
- Fixed history ranges starting past the end selecting the last command, including when using
`history --run`.

## 4.3.0 (October 13, 2026)

Expand Down
18 changes: 17 additions & 1 deletion cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -5501,6 +5501,18 @@ def do_history(self, args: argparse.Namespace) -> bool | None:
# If an argument was supplied, then retrieve partial contents of the history, otherwise retrieve it all
history = self._get_history(args)

# Fail fast on an index or range which selects nothing, before running, editing, or saving anything.
# A string or regex search which finds nothing is not an error.
if not history and args.arg:
try:
int(args.arg)
selects_by_index = True
except ValueError:
selects_by_index = ".." in args.arg or ":" in args.arg
if selects_by_index:
self.perror("No history items in range")
return None

if args.run:
if not args.arg:
self.perror("Cowardly refusing to run all previously entered commands.")
Expand Down Expand Up @@ -5555,9 +5567,13 @@ def _get_history(self, args: argparse.Namespace) -> dict[int, HistoryItem]:
if args.arg:
try:
int_arg = int(args.arg)
return {int_arg: self.history.get(int_arg)}
except ValueError:
pass
else:
# An index past either end selects nothing. Index 0 still raises its own error from get().
if abs(int_arg) > len(self.history):
return {}
return {int_arg: self.history.get(int_arg)}

if ".." in args.arg or ":" in args.arg:
# Get a slice of history
Expand Down
2 changes: 1 addition & 1 deletion cmd2/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ def span(self, span: str, include_persisted: bool = False) -> dict[int, "History

start_token = results.group("start")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a start index is given, span() ignores include_persisted and session_start_index, so explicit ranges reach into persisted history without -a.

If this is a bug, which honestly I'm not sure if we should consider it one or not, it was there before this branch and should probably belong in its own fix rather than this PR.

I'm open to thoughts on what the correct behavior should be here. @kmvanbrunt Do you have an opinion?

if start_token:
start = min(self._zero_based_index(start_token), len(self) - 1)
start = self._zero_based_index(start_token)
if start < 0:
start = max(0, len(self) + start)
else:
Expand Down
160 changes: 160 additions & 0 deletions tests/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,19 @@ def test_history_class_span(hist) -> None:
hist.span(tryit)


@pytest.mark.parametrize("suffix", [":", ":999", ":-1", "..", "..999", "..-1"])
def test_history_span_past_end(hist, suffix) -> None:
assert hist.span(f"{len(hist) + 1}{suffix}") == {}


@pytest.mark.parametrize(
("span", "indices"),
[("4:", [4]), ("1:999", [1, 2, 3, 4]), ("-999:-1", [1, 2, 3, 4]), ("999:1", [])],
)
def test_history_span_boundaries(hist, span, indices) -> None:
assert list(hist.span(span)) == indices


def test_persisted_history_span(persisted_hist) -> None:
span = persisted_hist.span("2..")
assert len(span) == 5
Expand Down Expand Up @@ -644,6 +657,153 @@ def test_history_with_span_end(base_app) -> None:
verify_hi_last_result(base_app, 2)


# Each of these selects nothing when history holds a single item
EMPTY_HISTORY_SPANS = ["2", "5", "999", "-2", "2:", "2:3", "2..", "2..3", "2:1"]


@pytest.fixture
def debug_history_app(base_app) -> cmd2.Cmd:
"""App whose only history item is 'set debug true', with debug reset so a rerun is detectable"""
run_cmd(base_app, "set debug true")
assert len(base_app.history) == 1
base_app.debug = False
return base_app


@pytest.mark.parametrize("span", EMPTY_HISTORY_SPANS)
def test_history_empty_span(debug_history_app, span) -> None:
out, err = run_cmd(debug_history_app, f"history {span}")

assert out == []
assert err == ["No history items in range"]
assert debug_history_app.last_result is False


@pytest.mark.parametrize("span", EMPTY_HISTORY_SPANS)
def test_history_run_empty_span(debug_history_app, span) -> None:
out, err = run_cmd(debug_history_app, f"history --run {span}")

assert out == []
assert err == ["No history items in range"]
assert debug_history_app.last_result is False
assert debug_history_app.debug is False
assert len(debug_history_app.history) == 1


@pytest.mark.parametrize("span", EMPTY_HISTORY_SPANS)
def test_history_edit_empty_span(debug_history_app, monkeypatch, span) -> None:
mkstemp_mock = mock.MagicMock(name="mkstemp", wraps=tempfile.mkstemp)
monkeypatch.setattr("cmd2.cmd2.tempfile.mkstemp", mkstemp_mock)
edit_mock = mock.MagicMock(name="run_editor")
monkeypatch.setattr("cmd2.Cmd.run_editor", edit_mock)
run_script_mock = mock.MagicMock(name="do_run_script")
monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock)

out, err = run_cmd(debug_history_app, f"history --edit {span}")

assert out == []
assert err == ["No history items in range"]
assert debug_history_app.last_result is False
mkstemp_mock.assert_not_called()
edit_mock.assert_not_called()
run_script_mock.assert_not_called()
assert debug_history_app.debug is False


@pytest.mark.parametrize("span", EMPTY_HISTORY_SPANS)
def test_history_output_file_empty_span(debug_history_app, tmp_path, span) -> None:
existing_file = tmp_path / "existing.txt"
existing_file.write_text("keep me\n")
missing_file = tmp_path / "missing.txt"

for output_file in (existing_file, missing_file):
out, err = run_cmd(debug_history_app, f'history --output-file "{output_file}" {span}')

assert out == []
assert err == ["No history items in range"]
assert debug_history_app.last_result is False

assert existing_file.read_text() == "keep me\n"
assert not missing_file.exists()


@pytest.mark.parametrize("action", ["", "--run", "--edit", "--output-file"])
def test_history_index_past_end_with_three_items(base_app, monkeypatch, tmp_path, action) -> None:
run_cmd(base_app, "help")
run_cmd(base_app, "shortcuts")
run_cmd(base_app, "set debug true")
base_app.debug = False
edit_mock = mock.MagicMock(name="run_editor")
monkeypatch.setattr("cmd2.Cmd.run_editor", edit_mock)
output_file = tmp_path / "existing.txt"
output_file.write_text("keep me\n")
if action == "--output-file":
action = f'--output-file "{output_file}"'

out, err = run_cmd(base_app, f"history {action} 5")

assert out == []
assert err == ["No history items in range"]
assert base_app.last_result is False
assert len(base_app.history) == 3
assert base_app.debug is False
edit_mock.assert_not_called()
assert output_file.read_text() == "keep me\n"


@pytest.mark.parametrize("span", ["1", "-1", "1:", "-1:"])
def test_history_run_valid_span(debug_history_app, span) -> None:
_out, err = run_cmd(debug_history_app, f"history --run {span}")

assert err == []
assert debug_history_app.last_result is True
assert debug_history_app.debug is True


@pytest.mark.parametrize("span", ["1", "-1", "1:", "-1:"])
def test_history_output_file_valid_span(debug_history_app, tmp_path, span) -> None:
output_file = tmp_path / "out.txt"
output_file.write_text("old contents\n")

run_cmd(debug_history_app, f'history --output-file "{output_file}" {span}')

assert debug_history_app.last_result is True
assert output_file.read_text() == "set debug true\n"


@pytest.mark.parametrize("span", ["1", "-1", "1:"])
def test_history_edit_valid_span(debug_history_app, monkeypatch, span) -> None:
edited = []

def fake_editor(_self, fname) -> None:
with open(fname) as f:
edited.append(f.read())

monkeypatch.setattr("cmd2.Cmd.run_editor", fake_editor)
run_script_mock = mock.MagicMock(name="do_run_script")
monkeypatch.setattr("cmd2.Cmd.do_run_script", run_script_mock)

_out, err = run_cmd(debug_history_app, f"history --edit {span}")

assert err == []
assert edited == ["set debug true\n"]
run_script_mock.assert_called_once()


@pytest.mark.parametrize("search", ["no_such_command", "/no_such_regex/"])
def test_history_search_without_match_is_not_an_error(debug_history_app, search) -> None:
out, err = run_cmd(debug_history_app, f"history {search}")

assert out == []
assert err == []
assert debug_history_app.last_result == {}


def test_history_index_zero_error_unchanged(debug_history_app) -> None:
with pytest.raises(IndexError, match=r"The first command in history is command 1\."):
debug_history_app.onecmd("history 0")


def test_history_with_span_index_error(base_app) -> None:
run_cmd(base_app, "help")
run_cmd(base_app, "help history")
Expand Down
Loading