Skip to content

Commit a9068de

Browse files
authored
Merge pull request #1065 from python-cmd2/history_fix
History fix
2 parents 4da77c4 + 3f0f277 commit a9068de

File tree

4 files changed

+154
-194
lines changed

4 files changed

+154
-194
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## 2.0.0 (TBD, 2021)
22
* Bug Fixes
3+
* Fixed issue where history indexes could get repeated
34
* Fixed issue where TableCreator was tossing blank last lines
45
* Breaking Changes
56
* `cmd2` 2.0 supports Python 3.6+ (removed support for Python 3.5)

cmd2/cmd2.py

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,7 @@ def find_subcommand(action: argparse.ArgumentParser, subcmd_names: List[str]) ->
788788
if choice_name == cur_subcmd:
789789
return find_subcommand(choice, subcmd_names)
790790
break
791-
raise CommandSetRegistrationError('Could not find sub-command "{}"'.format(full_command_name))
791+
raise CommandSetRegistrationError('Could not find subcommand "{}"'.format(full_command_name))
792792

793793
target_parser = find_subcommand(command_parser, subcommand_names)
794794

@@ -4235,13 +4235,13 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]:
42354235
self.perror("Cowardly refusing to run all previously entered commands.")
42364236
self.perror("If this is what you want to do, specify '1:' as the range of history.")
42374237
else:
4238-
return self.runcmds_plus_hooks(history)
4238+
return self.runcmds_plus_hooks(list(history.values()))
42394239
elif args.edit:
42404240
import tempfile
42414241

42424242
fd, fname = tempfile.mkstemp(suffix='.txt', text=True)
42434243
with os.fdopen(fd, 'w') as fobj:
4244-
for command in history:
4244+
for command in history.values():
42454245
if command.statement.multiline_command:
42464246
fobj.write('{}\n'.format(command.expanded))
42474247
else:
@@ -4255,7 +4255,7 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]:
42554255
elif args.output_file:
42564256
try:
42574257
with open(os.path.expanduser(args.output_file), 'w') as fobj:
4258-
for item in history:
4258+
for item in history.values():
42594259
if item.statement.multiline_command:
42604260
fobj.write('{}\n'.format(item.expanded))
42614261
else:
@@ -4266,33 +4266,31 @@ def do_history(self, args: argparse.Namespace) -> Optional[bool]:
42664266
else:
42674267
self.pfeedback('{} command{} saved to {}'.format(len(history), plural, args.output_file))
42684268
elif args.transcript:
4269-
self._generate_transcript(history, args.transcript)
4269+
self._generate_transcript(list(history.values()), args.transcript)
42704270
else:
42714271
# Display the history items retrieved
4272-
for hi in history:
4273-
self.poutput(hi.pr(script=args.script, expanded=args.expanded, verbose=args.verbose))
4272+
for idx, hi in history.items():
4273+
self.poutput(hi.pr(idx, script=args.script, expanded=args.expanded, verbose=args.verbose))
4274+
4275+
def _get_history(self, args: argparse.Namespace) -> Dict[int, HistoryItem]:
4276+
"""If an argument was supplied, then retrieve partial contents of the history; otherwise retrieve entire history.
42744277
4275-
def _get_history(self, args: argparse.Namespace) -> List[HistoryItem]:
4276-
"""If an argument was supplied, then retrieve partial contents of the history; otherwise retrieve entire history."""
4278+
This function returns a dictionary with history items keyed by their 1-based index in ascending order.
4279+
"""
42774280
if args.arg:
4278-
# If a character indicating a slice is present, retrieve a slice of the history
4279-
arg = args.arg
4280-
arg_is_int = False
42814281
try:
4282-
int(arg)
4283-
arg_is_int = True
4282+
int_arg = int(args.arg)
4283+
return {int_arg: self.history.get(int_arg)}
42844284
except ValueError:
42854285
pass
42864286

4287-
if '..' in arg or ':' in arg:
4287+
if '..' in args.arg or ':' in args.arg:
42884288
# Get a slice of history
4289-
history = self.history.span(arg, args.all)
4290-
elif arg_is_int:
4291-
history = [self.history.get(arg)]
4292-
elif arg.startswith(r'/') and arg.endswith(r'/'):
4293-
history = self.history.regex_search(arg, args.all)
4289+
history = self.history.span(args.arg, args.all)
4290+
elif args.arg.startswith(r'/') and args.arg.endswith(r'/'):
4291+
history = self.history.regex_search(args.arg, args.all)
42944292
else:
4295-
history = self.history.str_search(arg, args.all)
4293+
history = self.history.str_search(args.arg, args.all)
42964294
else:
42974295
# Get a copy of the history so it doesn't get mutated while we are using it
42984296
history = self.history.span(':', args.all)
@@ -5118,7 +5116,7 @@ def _resolve_func_self(self, cmd_support_func: Callable, cmd_self: Union[Command
51185116
instance of each type, using type is a reasonably safe way to resolve the correct object instance
51195117
51205118
:param cmd_support_func: command support function. This could be a completer or namespace provider
5121-
:param cmd_self: The `self` associated with the command or sub-command
5119+
:param cmd_self: The `self` associated with the command or subcommand
51225120
:return:
51235121
"""
51245122
# figure out what class the command support function was defined in

cmd2/history.py

Lines changed: 59 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55

66
import re
77
from typing import (
8-
List,
8+
Callable,
9+
Dict,
10+
Optional,
911
Union,
1012
)
1113

@@ -27,7 +29,6 @@ class HistoryItem:
2729
_ex_listformat = ' {:>4}x {}'
2830

2931
statement = attr.ib(default=None, validator=attr.validators.instance_of(Statement))
30-
idx = attr.ib(default=None, validator=attr.validators.instance_of(int))
3132

3233
def __str__(self):
3334
"""A convenient human readable representation of the history item"""
@@ -50,20 +51,24 @@ def expanded(self) -> str:
5051
"""
5152
return self.statement.expanded_command_line
5253

53-
def pr(self, script=False, expanded=False, verbose=False) -> str:
54+
def pr(self, idx: int, script: bool = False, expanded: bool = False, verbose: bool = False) -> str:
5455
"""Represent this item in a pretty fashion suitable for printing.
5556
5657
If you pass verbose=True, script and expanded will be ignored
5758
59+
:param idx: The 1-based index of this item in the history list
60+
:param script: True if formatting for a script (No item numbers)
61+
:param expanded: True if expanded command line should be printed
62+
:param verbose: True if expanded and raw should both appear when they are different
5863
:return: pretty print string version of a HistoryItem
5964
"""
6065
if verbose:
6166
raw = self.raw.rstrip()
6267
expanded = self.expanded
6368

64-
ret_str = self._listformat.format(self.idx, raw)
69+
ret_str = self._listformat.format(idx, raw)
6570
if raw != expanded:
66-
ret_str += '\n' + self._ex_listformat.format(self.idx, expanded)
71+
ret_str += '\n' + self._ex_listformat.format(idx, expanded)
6772
else:
6873
if expanded:
6974
ret_str = self.expanded
@@ -80,7 +85,7 @@ def pr(self, script=False, expanded=False, verbose=False) -> str:
8085

8186
# Display a numbered list if not writing to a script
8287
if not script:
83-
ret_str = self._listformat.format(self.idx, ret_str)
88+
ret_str = self._listformat.format(idx, ret_str)
8489

8590
return ret_str
8691

@@ -121,21 +126,20 @@ def append(self, new: Statement) -> None:
121126
:param new: Statement object which will be composed into a HistoryItem
122127
and added to the end of the list
123128
"""
124-
history_item = HistoryItem(new, len(self) + 1)
129+
history_item = HistoryItem(new)
125130
super().append(history_item)
126131

127132
def clear(self) -> None:
128133
"""Remove all items from the History list."""
129134
super().clear()
130135
self.start_session()
131136

132-
def get(self, index: Union[int, str]) -> HistoryItem:
137+
def get(self, index: int) -> HistoryItem:
133138
"""Get item from the History list using 1-based indexing.
134139
135-
:param index: optional item to get (index as either integer or string)
140+
:param index: optional item to get
136141
:return: a single :class:`~cmd2.history.HistoryItem`
137142
"""
138-
index = int(index)
139143
if index == 0:
140144
raise IndexError('The first command in history is command 1.')
141145
elif index < 0:
@@ -155,8 +159,7 @@ def get(self, index: Union[int, str]) -> HistoryItem:
155159
# This regex will match 1, -1, 10, -10, but not 0 or -0.
156160
#
157161
# (?P<separator>:|(\.{2,}))? create a capture group named 'separator' which matches either
158-
# a colon or two periods. This group is optional so we can
159-
# match a string like '3'
162+
# a colon or two periods.
160163
#
161164
# (?P<end>-?[1-9]{1}\d*)? create a capture group named 'end' which matches an
162165
# optional minus sign, followed by exactly one non-zero
@@ -168,19 +171,18 @@ def get(self, index: Union[int, str]) -> HistoryItem:
168171
# \s*$ match any whitespace at the end of the input. This is here so
169172
# you don't have to trim the input
170173
#
171-
spanpattern = re.compile(r'^\s*(?P<start>-?[1-9]\d*)?(?P<separator>:|(\.{2,}))?(?P<end>-?[1-9]\d*)?\s*$')
174+
spanpattern = re.compile(r'^\s*(?P<start>-?[1-9]\d*)?(?P<separator>:|(\.{2,}))(?P<end>-?[1-9]\d*)?\s*$')
172175

173-
def span(self, span: str, include_persisted: bool = False) -> List[HistoryItem]:
174-
"""Return an index or slice of the History list,
176+
def span(self, span: str, include_persisted: bool = False) -> Dict[int, HistoryItem]:
177+
"""Return a slice of the History list
175178
176179
:param span: string containing an index or a slice
177180
:param include_persisted: if True, then retrieve full results including from persisted history
178-
:return: a list of HistoryItems
181+
:return: a dictionary of history items keyed by their 1-based index in ascending order,
182+
or an empty dictionary if no results were found
179183
180184
This method can accommodate input in any of these forms:
181185
182-
a
183-
-a
184186
a..b or a:b
185187
a.. or a:
186188
..a or :a
@@ -197,89 +199,67 @@ def span(self, span: str, include_persisted: bool = False) -> List[HistoryItem]:
197199
- off by one errors
198200
199201
"""
200-
if span.lower() in ('*', '-', 'all'):
201-
span = ':'
202202
results = self.spanpattern.search(span)
203203
if not results:
204204
# our regex doesn't match the input, bail out
205205
raise ValueError('History indices must be positive or negative integers, and may not be zero.')
206206

207-
sep = results.group('separator')
208207
start = results.group('start')
209208
if start:
210-
start = self._zero_based_index(start)
209+
start = min(self._zero_based_index(start), len(self) - 1)
210+
if start < 0:
211+
start = max(0, len(self) + start)
212+
else:
213+
start = 0 if include_persisted else self.session_start_index
214+
211215
end = results.group('end')
212216
if end:
213-
end = int(end)
214-
# modify end so it's inclusive of the last element
215-
if end == -1:
216-
# -1 as the end means include the last command in the array, which in pythonic
217-
# terms means to not provide an ending index. If you put -1 as the ending index
218-
# python excludes the last item in the list.
219-
end = None
220-
elif end < -1:
221-
# if the ending is smaller than -1, make it one larger so it includes
222-
# the element (python native indices exclude the last referenced element)
223-
end += 1
224-
225-
if start is not None and end is not None:
226-
# we have both start and end, return a slice of history
227-
result = self[start:end]
228-
elif start is not None and sep is not None:
229-
# take a slice of the array
230-
result = self[start:]
231-
elif end is not None and sep is not None:
232-
if include_persisted:
233-
result = self[:end]
234-
else:
235-
result = self[self.session_start_index : end]
236-
elif start is not None:
237-
# there was no separator so it's either a positive or negative integer
238-
result = [self[start]]
217+
end = min(int(end), len(self))
218+
if end < 0:
219+
end = max(0, len(self) + end + 1)
239220
else:
240-
# we just have a separator, return the whole list
241-
if include_persisted:
242-
result = self[:]
243-
else:
244-
result = self[self.session_start_index :]
245-
return result
221+
end = len(self)
246222

247-
def str_search(self, search: str, include_persisted: bool = False) -> List[HistoryItem]:
223+
return self._build_result_dictionary(start, end)
224+
225+
def str_search(self, search: str, include_persisted: bool = False) -> Dict[int, HistoryItem]:
248226
"""Find history items which contain a given string
249227
250228
:param search: the string to search for
251229
:param include_persisted: if True, then search full history including persisted history
252-
:return: a list of history items, or an empty list if the string was not found
230+
:return: a dictionary of history items keyed by their 1-based index in ascending order,
231+
or an empty dictionary if the string was not found
253232
"""
254233

255-
def isin(history_item):
234+
def isin(history_item: HistoryItem) -> bool:
256235
"""filter function for string search of history"""
257236
sloppy = utils.norm_fold(search)
258237
inraw = sloppy in utils.norm_fold(history_item.raw)
259238
inexpanded = sloppy in utils.norm_fold(history_item.expanded)
260239
return inraw or inexpanded
261240

262-
search_list = self if include_persisted else self[self.session_start_index :]
263-
return [item for item in search_list if isin(item)]
241+
start = 0 if include_persisted else self.session_start_index
242+
return self._build_result_dictionary(start, len(self), isin)
264243

265-
def regex_search(self, regex: str, include_persisted: bool = False) -> List[HistoryItem]:
244+
def regex_search(self, regex: str, include_persisted: bool = False) -> Dict[int, HistoryItem]:
266245
"""Find history items which match a given regular expression
267246
268247
:param regex: the regular expression to search for.
269248
:param include_persisted: if True, then search full history including persisted history
270-
:return: a list of history items, or an empty list if the string was not found
249+
:return: a dictionary of history items keyed by their 1-based index in ascending order,
250+
or an empty dictionary if the regex was not matched
271251
"""
272252
regex = regex.strip()
273253
if regex.startswith(r'/') and regex.endswith(r'/'):
274254
regex = regex[1:-1]
275255
finder = re.compile(regex, re.DOTALL | re.MULTILINE)
276256

277-
def isin(hi):
257+
def isin(hi: HistoryItem) -> bool:
278258
"""filter function for doing a regular expression search of history"""
279-
return finder.search(hi.raw) or finder.search(hi.expanded)
259+
return bool(finder.search(hi.raw) or finder.search(hi.expanded))
280260

281-
search_list = self if include_persisted else self[self.session_start_index :]
282-
return [itm for itm in search_list if isin(itm)]
261+
start = 0 if include_persisted else self.session_start_index
262+
return self._build_result_dictionary(start, len(self), isin)
283263

284264
def truncate(self, max_length: int) -> None:
285265
"""Truncate the length of the history, dropping the oldest items if necessary
@@ -294,3 +274,17 @@ def truncate(self, max_length: int) -> None:
294274
elif len(self) > max_length:
295275
last_element = len(self) - max_length
296276
del self[0:last_element]
277+
278+
def _build_result_dictionary(
279+
self, start: int, end: int, filter_func: Optional[Callable[[HistoryItem], bool]] = None
280+
) -> Dict[int, HistoryItem]:
281+
"""
282+
Build history search results
283+
:param start: start index to search from
284+
:param end: end index to stop searching (exclusive)
285+
"""
286+
results: Dict[int, HistoryItem] = dict()
287+
for index in range(start, end):
288+
if filter_func is None or filter_func(self[index]):
289+
results[index + 1] = self[index]
290+
return results

0 commit comments

Comments
 (0)