Repository navigation
Fix history ranges starting past the end #1778
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
7193751
1ae4e16
b1c9466
d1aa374
7f98eb4
ad622ad
feefbf4
6b48b75
9ad0cf8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -153,7 +153,10 @@ | |
| SkipPostcommandHooks, | ||
| ) | ||
| from .history import ( | ||
| NO_MATCHING_HISTORY_ITEMS, | ||
| History, | ||
| HistoryFormatError, | ||
| HistoryIndexError, | ||
| HistoryItem, | ||
| ) | ||
| from .parsing import ( | ||
|
|
@@ -5498,18 +5501,23 @@ def do_history(self, args: argparse.Namespace) -> bool | None: | |
|
|
||
| return None | ||
|
|
||
| if args.run and not args.arg: | ||
| self.perror("Cowardly refusing to run all previously entered commands.") | ||
| self.perror("If this is what you want to do, specify '1:' as the range of history.") | ||
| return None | ||
|
|
||
| # If an argument was supplied, then retrieve partial contents of the history, otherwise retrieve it all | ||
| history = self._get_history(args) | ||
| try: | ||
| history = self._get_history(args) | ||
| except (HistoryIndexError, HistoryFormatError) as ex: | ||
| self.perror(str(ex)) | ||
| return None | ||
|
|
||
| if args.run: | ||
| if not args.arg: | ||
| self.perror("Cowardly refusing to run all previously entered commands.") | ||
| self.perror("If this is what you want to do, specify '1:' as the range of history.") | ||
| else: | ||
| stop = self.runcmds_plus_hooks(list(history.values())) | ||
| self.last_result = True | ||
| return stop | ||
| elif args.edit: | ||
| stop = self.runcmds_plus_hooks(list(history.values())) | ||
| self.last_result = True | ||
| return stop | ||
| if args.edit: | ||
| fd, fname = tempfile.mkstemp(suffix=".txt", text=True) | ||
| fobj: TextIO | ||
| with os.fdopen(fd, "w") as fobj: | ||
|
|
@@ -5550,14 +5558,18 @@ def do_history(self, args: argparse.Namespace) -> bool | None: | |
| def _get_history(self, args: argparse.Namespace) -> dict[int, HistoryItem]: | ||
| """If an argument was supplied, then retrieve partial contents of the history; otherwise retrieve entire history. | ||
|
|
||
| This function returns a dictionary with history items keyed by their 1-based index in ascending order. | ||
| This function returns a dictionary with history items keyed by their positive 1-based index in ascending order. | ||
| Selection errors, including empty selections for actions, propagate to the caller. | ||
| """ | ||
| if args.arg: | ||
| try: | ||
| int_arg = int(args.arg) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Checking r |
||
| return {int_arg: self.history.get(int_arg)} | ||
| except ValueError: | ||
| pass | ||
| else: | ||
| item = self.history.get(int_arg) | ||
| index = len(self.history) + int_arg + 1 if int_arg < 0 else int_arg | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Duplicated index arithmetic: the negative-to-positive conversion is now in three places.
|
||
| return {index: item} | ||
|
|
||
| if ".." in args.arg or ":" in args.arg: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Searches containing The fix is to check for |
||
| # Get a slice of history | ||
|
|
@@ -5569,6 +5581,10 @@ def _get_history(self, args: argparse.Namespace) -> dict[int, HistoryItem]: | |
| else: | ||
| # Get a copy of the history so it doesn't get mutated while we are using it | ||
| history = self.history.span(":", args.all) | ||
|
|
||
| # An empty display is harmless, but actions must have something to run, edit, or save. | ||
| if not history and (args.run or args.edit or args.output_file): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Empty-selection check is in the wrong function: it lives in Moving the check to |
||
| raise HistoryIndexError(NO_MATCHING_HISTORY_ITEMS) | ||
| return history | ||
|
|
||
| def _initialize_history(self, hist_file: str) -> None: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,11 @@ | |
| shlex_split, | ||
| ) | ||
|
|
||
| NO_HISTORY_ITEMS = "No history items in range" | ||
| NO_MATCHING_HISTORY_ITEMS = "No matching history items" | ||
| FIRST_HISTORY_INDEX = "The first command in history is command 1." | ||
| INVALID_HISTORY_RANGE = "History indices must be positive or negative integers, and may not be zero." | ||
|
|
||
|
|
||
| def single_line_format(statement: Statement) -> str: | ||
| """Format a command line to display on a single line. | ||
|
|
@@ -131,6 +136,14 @@ def from_dict(source_dict: dict[str, Any]) -> "HistoryItem": | |
| return HistoryItem(Statement.from_dict(statement_dict)) | ||
|
|
||
|
|
||
| class HistoryIndexError(IndexError): | ||
| """Raised when a history index is invalid or a bounded range selects no items.""" | ||
|
|
||
|
|
||
| class HistoryFormatError(ValueError): | ||
| """Raised when a history range has invalid syntax or a zero endpoint.""" | ||
|
|
||
|
|
||
| class History(list[HistoryItem]): | ||
| """A list of [HistoryItem][cmd2.history.HistoryItem] objects with additional methods for searching and managing the list. | ||
|
|
||
|
|
@@ -190,9 +203,12 @@ def get(self, index: int) -> HistoryItem: | |
|
|
||
| :param index: optional item to get | ||
| :return: a single [cmd2.history.HistoryItem][] | ||
| :raises HistoryIndexError: if index is zero or outside the history | ||
| """ | ||
| if index == 0: | ||
| raise IndexError("The first command in history is command 1.") | ||
| raise HistoryIndexError(f"{NO_HISTORY_ITEMS}. {FIRST_HISTORY_INDEX}") | ||
| if abs(index) > len(self): | ||
| raise HistoryIndexError(NO_HISTORY_ITEMS) | ||
| if index < 0: | ||
| return self[index] | ||
| return self[index - 1] | ||
|
|
@@ -227,9 +243,12 @@ def span(self, span: str, include_persisted: bool = False) -> dict[int, "History | |
| """Return a slice of the History list. | ||
|
|
||
| :param span: string containing an index or a slice | ||
| :param include_persisted: if True, then retrieve full results including from persisted history | ||
| :param include_persisted: if True, include persisted history for unbounded ranges. | ||
| Ranges with either endpoint always use the full history. | ||
| :return: a dictionary of history items keyed by their 1-based index in ascending order, | ||
| or an empty dictionary if no results were found | ||
| or an empty dictionary if an unbounded range (``:`` or ``..``) has no results | ||
| :raises HistoryFormatError: if the range format is invalid or an index is zero | ||
| :raises HistoryIndexError: if a range with an explicit start or end selects no history items | ||
|
|
||
| This method can accommodate input in any of these forms: | ||
|
|
||
|
|
@@ -252,24 +271,27 @@ def span(self, span: str, include_persisted: bool = False) -> dict[int, "History | |
| results = self.spanpattern.search(span) | ||
| if not results: | ||
| # our regex doesn't match the input, bail out | ||
| raise ValueError("History indices must be positive or negative integers, and may not be zero.") | ||
| raise HistoryFormatError(f"{INVALID_HISTORY_RANGE} {FIRST_HISTORY_INDEX}") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Misleading "command 1" hint: |
||
|
|
||
| start_token = results.group("start") | ||
|
tleonhardt marked this conversation as resolved.
|
||
| end_token = results.group("end") | ||
| if start_token: | ||
| start = min(self._zero_based_index(start_token), len(self) - 1) | ||
| start = self._zero_based_index(start_token) | ||
|
tleonhardt marked this conversation as resolved.
|
||
| if start < 0: | ||
| start = max(0, len(self) + start) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A typo can replay all history: Rejecting a negative start that falls before the first command, as is already done for past-end starts, would fixt this edge case. |
||
| else: | ||
| start = 0 if include_persisted else self.session_start_index | ||
| start = 0 if include_persisted or end_token else self.session_start_index | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This one is a judgement call. We could keep it as-is or require @AryanRG @kmvanbrunt If either of you have an opinion on this, please weigh in. |
||
|
|
||
| end_token = results.group("end") | ||
| if end_token: | ||
| end = min(int(end_token), len(self)) | ||
| if end < 0: | ||
| end = max(0, len(self) + end + 1) | ||
| else: | ||
| end = len(self) | ||
|
|
||
| if (start_token or end_token) and (start >= len(self) or end <= start): | ||
|
tleonhardt marked this conversation as resolved.
|
||
| raise HistoryIndexError(NO_HISTORY_ITEMS) | ||
|
|
||
| return self._build_result_dictionary(start, end) | ||
|
|
||
| def str_search(self, search: str, include_persisted: bool = False) -> dict[int, "HistoryItem"]: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.