Skip to content

Commit feefbf4

Browse files
committed
Centralize history selection errors
1 parent ad622ad commit feefbf4

4 files changed

Lines changed: 131 additions & 54 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
## 4.4.0 (TBD)
22

33
- Breaking Changes
4-
- `History.span()` now raises `IndexError` when a range with an explicit start selects no
5-
history items, rather than returning an empty dictionary. Open-start ranges can still return
6-
an empty dictionary, and negative starts still clamp to the beginning of history.
4+
- `History.get()` and `History.span()` now raise `HistoryIndexError`, an `IndexError` subclass,
5+
for invalid indices and bounded ranges selecting no history items. This includes end-only
6+
ranges. Unbounded ranges (`:` and `..`) can still return an empty dictionary, and negative
7+
starts still clamp to the beginning of history. Invalid indices, including zero, now use the
8+
message `No history items in range`.
79
- Bug Fixes
810
- Fixed history ranges starting past the end selecting the last command. Invalid indices and
9-
explicit-start ranges selecting no items report `No history items in range` before running,
10-
editing, or saving commands. Open-start ranges in an empty session behave like plain
11-
`history`.
11+
bounded ranges selecting no items report `No history items in range` before running, editing,
12+
or saving commands. Unbounded ranges in an empty session behave like plain `history`.
1213

1314
## 4.3.1 (TBD)
1415

‎cmd2/cmd2.py‎

Lines changed: 20 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@
154154
)
155155
from .history import (
156156
History,
157+
HistoryIndexError,
157158
HistoryItem,
158159
)
159160
from .parsing import (
@@ -5556,32 +5557,28 @@ def _get_history(self, args: argparse.Namespace) -> dict[int, HistoryItem] | Non
55565557
This function returns a dictionary with history items keyed by their 1-based index in ascending order,
55575558
or None after reporting an index or range which selects no items.
55585559
"""
5559-
if args.arg:
5560-
try:
5561-
int_arg = int(args.arg)
5562-
except ValueError:
5563-
pass
5564-
else:
5565-
# Index 0 still raises its own error from get().
5566-
if abs(int_arg) > len(self.history):
5567-
self.perror("No history items in range")
5568-
return None
5569-
return {int_arg: self.history.get(int_arg)}
5570-
5571-
if ".." in args.arg or ":" in args.arg:
5572-
# Get a slice of history
5560+
try:
5561+
if args.arg:
55735562
try:
5563+
int_arg = int(args.arg)
5564+
except ValueError:
5565+
pass
5566+
else:
5567+
return {int_arg: self.history.get(int_arg)}
5568+
5569+
if ".." in args.arg or ":" in args.arg:
5570+
# Get a slice of history
55745571
history = self.history.span(args.arg, args.all)
5575-
except IndexError as ex:
5576-
self.perror(str(ex))
5577-
return None
5578-
elif args.arg.startswith(r"/") and args.arg.endswith(r"/"):
5579-
history = self.history.regex_search(args.arg, args.all)
5572+
elif args.arg.startswith(r"/") and args.arg.endswith(r"/"):
5573+
history = self.history.regex_search(args.arg, args.all)
5574+
else:
5575+
history = self.history.str_search(args.arg, args.all)
55805576
else:
5581-
history = self.history.str_search(args.arg, args.all)
5582-
else:
5583-
# Get a copy of the history so it doesn't get mutated while we are using it
5584-
history = self.history.span(":", args.all)
5577+
# Get a copy of the history so it doesn't get mutated while we are using it
5578+
history = self.history.span(":", args.all)
5579+
except HistoryIndexError as ex:
5580+
self.perror(str(ex))
5581+
return None
55855582
return history
55865583

55875584
def _initialize_history(self, hist_file: str) -> None:

‎cmd2/history.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,10 @@ def from_dict(source_dict: dict[str, Any]) -> "HistoryItem":
131131
return HistoryItem(Statement.from_dict(statement_dict))
132132

133133

134+
class HistoryIndexError(IndexError):
135+
"""Raised when a history index is invalid or a bounded range selects no items."""
136+
137+
134138
class History(list[HistoryItem]):
135139
"""A list of [HistoryItem][cmd2.history.HistoryItem] objects with additional methods for searching and managing the list.
136140
@@ -190,9 +194,10 @@ def get(self, index: int) -> HistoryItem:
190194
191195
:param index: optional item to get
192196
:return: a single [cmd2.history.HistoryItem][]
197+
:raises HistoryIndexError: if index is zero or outside the history
193198
"""
194-
if index == 0:
195-
raise IndexError("The first command in history is command 1.")
199+
if index == 0 or abs(index) > len(self):
200+
raise HistoryIndexError("No history items in range")
196201
if index < 0:
197202
return self[index]
198203
return self[index - 1]
@@ -229,9 +234,9 @@ def span(self, span: str, include_persisted: bool = False) -> dict[int, "History
229234
:param span: string containing an index or a slice
230235
:param include_persisted: if True, then retrieve full results including from persisted history
231236
:return: a dictionary of history items keyed by their 1-based index in ascending order,
232-
or an empty dictionary if an open-start range has no results
237+
or an empty dictionary if an unbounded range (``:`` or ``..``) has no results
233238
:raises ValueError: if the range format is invalid or an index is zero
234-
:raises IndexError: if a range with an explicit start selects no history items
239+
:raises HistoryIndexError: if a range with an explicit start or end selects no history items
235240
236241
This method can accommodate input in any of these forms:
237242
@@ -272,8 +277,8 @@ def span(self, span: str, include_persisted: bool = False) -> dict[int, "History
272277
else:
273278
end = len(self)
274279

275-
if start_token and (start >= len(self) or end <= start):
276-
raise IndexError("No history items in range")
280+
if (start_token or end_token) and (start >= len(self) or end <= start):
281+
raise HistoryIndexError("No history items in range")
277282

278283
return self._build_result_dictionary(start, end)
279284

‎tests/test_history.py‎

Lines changed: 93 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import pytest
1111

1212
import cmd2
13+
from cmd2.history import HistoryIndexError
1314

1415
from .conftest import (
1516
normalize,
@@ -230,7 +231,7 @@ def test_history_class_span(hist) -> None:
230231

231232
@pytest.mark.parametrize("suffix", [":", ":999", ":-1", "..", "..999", "..-1"])
232233
def test_history_span_past_end(hist, suffix) -> None:
233-
with pytest.raises(IndexError, match="No history items in range"):
234+
with pytest.raises(HistoryIndexError, match="No history items in range"):
234235
hist.span(f"{len(hist) + 1}{suffix}")
235236

236237

@@ -244,26 +245,36 @@ def test_history_span_boundaries(hist, span, indices) -> None:
244245

245246
@pytest.mark.parametrize("span", ["999:1", "3:1", "2:-4", "1:-999"])
246247
def test_history_span_explicit_start_without_matches(hist, span) -> None:
247-
with pytest.raises(IndexError, match="No history items in range"):
248+
with pytest.raises(HistoryIndexError, match="No history items in range"):
248249
hist.span(span)
249250

250251

251252
@pytest.mark.parametrize("span", ["1:", "-1:"])
252253
def test_history_span_explicit_start_on_empty_history(hist, span) -> None:
253254
hist.clear()
254-
with pytest.raises(IndexError, match="No history items in range"):
255+
with pytest.raises(HistoryIndexError, match="No history items in range"):
255256
hist.span(span)
256257

257258

258-
@pytest.mark.parametrize("span", [":", "..", ":2", ":-999"])
259+
@pytest.mark.parametrize("span", [":", ".."])
259260
@pytest.mark.parametrize("empty_history", [False, True])
260-
def test_history_span_open_start_without_matches(hist, span, empty_history) -> None:
261+
def test_history_span_unbounded_without_matches(hist, span, empty_history) -> None:
261262
if empty_history:
262263
hist.clear()
263264
hist.start_session()
264265
assert hist.span(span) == {}
265266

266267

268+
@pytest.mark.parametrize("span", [":2", "..2", "..-3", ":-999"])
269+
@pytest.mark.parametrize("empty_history", [False, True])
270+
def test_history_span_end_only_without_matches(hist, span, empty_history) -> None:
271+
if empty_history:
272+
hist.clear()
273+
hist.start_session()
274+
with pytest.raises(HistoryIndexError, match="No history items in range"):
275+
hist.span(span)
276+
277+
267278
def test_persisted_history_span(persisted_hist) -> None:
268279
span = persisted_hist.span("2..")
269280
assert len(span) == 5
@@ -320,14 +331,33 @@ def test_history_class_get(hist) -> None:
320331
assert hist.get(3).statement.raw == "third"
321332
assert hist.get(-2) == hist[-2]
322333
assert hist.get(-1).statement.raw == "fourth"
334+
assert hist.get(len(hist)) == hist[-1]
335+
assert hist.get(-len(hist)) == hist[0]
323336

324-
with pytest.raises(IndexError):
337+
with pytest.raises(HistoryIndexError, match="No history items in range"):
325338
hist.get(0)
326339

327-
with pytest.raises(IndexError):
340+
with pytest.raises(HistoryIndexError, match="No history items in range"):
328341
hist.get(5)
329342

330343

344+
@pytest.mark.parametrize("index", [0, 5, -5, 10**100, -(10**100)])
345+
@pytest.mark.parametrize("empty_history", [False, True])
346+
def test_history_get_invalid_index_has_clear_error(hist, index, empty_history) -> None:
347+
if empty_history:
348+
hist.clear()
349+
with pytest.raises(HistoryIndexError, match="No history items in range") as error:
350+
hist.get(index)
351+
assert isinstance(error.value, IndexError)
352+
353+
354+
@pytest.mark.parametrize("index", [1, -1])
355+
def test_history_get_on_empty_history(hist, index) -> None:
356+
hist.clear()
357+
with pytest.raises(HistoryIndexError, match="No history items in range"):
358+
hist.get(index)
359+
360+
331361
def test_history_str_search(hist) -> None:
332362
items = hist.str_search("ir")
333363
assert len(items) == 2
@@ -681,12 +711,14 @@ def test_history_with_span_end(base_app) -> None:
681711

682712

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

686716

687-
@pytest.mark.parametrize("selector", ["", ":", "..", ":2"])
717+
@pytest.mark.parametrize(
718+
("selector", "empty_selection"), [("", False), (":", False), ("..", False), (":2", True), ("..-3", True)]
719+
)
688720
@pytest.mark.parametrize("empty_history", [False, True])
689-
def test_history_open_start_on_fresh_session(tmp_path, selector, empty_history) -> None:
721+
def test_history_selections_on_fresh_session(tmp_path, selector, empty_selection, empty_history) -> None:
690722
history_file = str(tmp_path / "history.dat")
691723
previous_app = cmd2.Cmd(persistent_history_file=history_file)
692724
if not empty_history:
@@ -699,13 +731,17 @@ def test_history_open_start_on_fresh_session(tmp_path, selector, empty_history)
699731
out, err = run_cmd(app, f"history {selector}")
700732

701733
assert out == []
702-
assert err == []
703-
assert app.last_result == {}
734+
if empty_selection:
735+
assert err == ["No history items in range"]
736+
assert app.last_result is False
737+
else:
738+
assert err == []
739+
assert app.last_result == {}
704740

705741

706742
@pytest.mark.parametrize("action", ["--run", "--edit", "--output-file"])
707-
@pytest.mark.parametrize("selector", [":", "..", ":2"])
708-
def test_history_open_start_empty_actions(base_app, hist, monkeypatch, tmp_path, action, selector) -> None:
743+
@pytest.mark.parametrize("selector", [":", ".."])
744+
def test_history_unbounded_empty_actions(base_app, hist, monkeypatch, tmp_path, action, selector) -> None:
709745
base_app.history = hist
710746
hist.start_session()
711747
output_file = tmp_path / "out.txt"
@@ -732,11 +768,29 @@ def test_history_open_start_empty_actions(base_app, hist, monkeypatch, tmp_path,
732768
assert base_app.last_result is True
733769

734770

735-
@pytest.mark.parametrize("span", ["3:1", "2:-4", "1:-999"])
771+
@pytest.mark.parametrize("span", [":2", "..2", "..-3"])
772+
def test_history_end_only_with_all_includes_persisted(base_app, hist, span) -> None:
773+
base_app.history = hist
774+
hist.start_session()
775+
776+
out, err = run_cmd(base_app, f"history --all {span}")
777+
778+
assert len(out) == 2
779+
assert err == []
780+
assert list(base_app.last_result) == [1, 2]
781+
782+
783+
@pytest.mark.parametrize(
784+
("span", "session_start"),
785+
[("3:1", 0), ("2:-4", 0), ("1:-999", 0), (":1", 2), ("..2", 2), ("..-3", 2), (":2", 4), ("..2", 4), ("..-3", 4)],
786+
)
736787
@pytest.mark.parametrize("action", ["", "--run", "--edit", "--output-file"])
737788
@pytest.mark.parametrize("debug", [False, True])
738-
def test_history_reversed_span_has_no_side_effects(base_app, hist, monkeypatch, tmp_path, span, action, debug) -> None:
789+
def test_history_empty_bounded_span_has_no_side_effects(
790+
base_app, hist, monkeypatch, tmp_path, span, session_start, action, debug
791+
) -> None:
739792
base_app.history = hist
793+
hist.session_start_index = session_start
740794
base_app.debug = debug
741795
mkstemp_mock = mock.MagicMock(name="mkstemp")
742796
monkeypatch.setattr("cmd2.cmd2.tempfile.mkstemp", mkstemp_mock)
@@ -905,9 +959,29 @@ def test_history_search_without_match_is_not_an_error(debug_history_app, search)
905959
assert debug_history_app.last_result == {}
906960

907961

908-
def test_history_index_zero_error_unchanged(debug_history_app) -> None:
909-
with pytest.raises(IndexError, match=r"The first command in history is command 1\."):
910-
debug_history_app.onecmd("history 0")
962+
@pytest.mark.parametrize("debug", [False, True])
963+
def test_history_index_zero_has_clean_error(debug_history_app, debug) -> None:
964+
debug_history_app.debug = debug
965+
out, err = run_cmd(debug_history_app, "history 0")
966+
assert out == []
967+
assert err == ["No history items in range"]
968+
assert debug_history_app.last_result is False
969+
970+
971+
@pytest.mark.parametrize("selector", ["1", "1:"])
972+
@pytest.mark.parametrize("debug", [False, True])
973+
def test_history_does_not_hide_unexpected_index_errors(base_app, hist, monkeypatch, selector, debug) -> None:
974+
base_app.history = hist
975+
base_app.debug = debug
976+
monkeypatch.setattr(type(hist), "__getitem__", mock.Mock(side_effect=IndexError("unexpected lookup failure")))
977+
978+
with pytest.raises(IndexError, match="unexpected lookup failure"):
979+
base_app.onecmd(f"history {selector}")
980+
981+
out, err = run_cmd(base_app, f"history {selector}")
982+
assert out == []
983+
assert err != ["No history items in range"]
984+
assert any("unexpected lookup failure" in line for line in err)
911985

912986

913987
def test_history_with_span_index_error(base_app) -> None:

0 commit comments

Comments
 (0)