Skip to content

Commit a127997

Browse files
authored
Merge pull request #645 from python-cmd2/history_improvements
History improvements
2 parents e365db3 + 4e91cc6 commit a127997

File tree

5 files changed

+432
-131
lines changed

5 files changed

+432
-131
lines changed

cmd2/cmd2.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3231,15 +3231,22 @@ def do_history(self, args: argparse.Namespace) -> None:
32313231
# If a character indicating a slice is present, retrieve
32323232
# a slice of the history
32333233
arg = args.arg
3234+
arg_is_int = False
3235+
try:
3236+
int(arg)
3237+
arg_is_int = True
3238+
except ValueError:
3239+
pass
3240+
32343241
if '..' in arg or ':' in arg:
3235-
try:
3236-
# Get a slice of history
3237-
history = self.history.span(arg)
3238-
except IndexError:
3239-
history = self.history.get(arg)
3242+
# Get a slice of history
3243+
history = self.history.span(arg)
3244+
elif arg_is_int:
3245+
history = [self.history.get(arg)]
3246+
elif arg.startswith(r'/') and arg.endswith(r'/'):
3247+
history = self.history.regex_search(arg)
32403248
else:
3241-
# Get item(s) from history by index or string search
3242-
history = self.history.get(arg)
3249+
history = self.history.str_search(arg)
32433250
else:
32443251
# If no arg given, then retrieve the entire history
32453252
cowardly_refuse_to_run = True

cmd2/history.py

Lines changed: 146 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import re
77

8-
from typing import List, Optional, Union
8+
from typing import List, Union
99

1010
from . import utils
1111
from .parsing import Statement
@@ -60,108 +60,167 @@ def pr(self, script=False, expanded=False, verbose=False) -> str:
6060

6161

6262
class History(list):
63-
""" A list of HistoryItems that knows how to respond to user requests. """
63+
"""A list of HistoryItems that knows how to respond to user requests.
64+
65+
Here are some key methods:
66+
67+
select() - parse user input and return a list of relevant history items
68+
str_search() - return a list of history items which contain the given string
69+
regex_search() - return a list of history items which match a given regex
70+
get() - return a single element of the list, using 1 based indexing
71+
span() - given a 1-based slice, return the appropriate list of history items
72+
73+
"""
6474

6575
# noinspection PyMethodMayBeStatic
66-
def _zero_based_index(self, onebased: int) -> int:
76+
def _zero_based_index(self, onebased: Union[int, str]) -> int:
6777
"""Convert a one-based index to a zero-based index."""
68-
result = onebased
78+
result = int(onebased)
6979
if result > 0:
7080
result -= 1
7181
return result
7282

73-
def _to_index(self, raw: str) -> Optional[int]:
74-
if raw:
75-
result = self._zero_based_index(int(raw))
76-
else:
77-
result = None
78-
return result
83+
def append(self, new: Statement) -> None:
84+
"""Append a HistoryItem to end of the History list
7985
80-
spanpattern = re.compile(r'^\s*(?P<start>-?\d+)?\s*(?P<separator>:|(\.{2,}))?\s*(?P<end>-?\d+)?\s*$')
86+
:param new: command line to convert to HistoryItem and add to the end of the History list
87+
"""
88+
new = HistoryItem(new)
89+
list.append(self, new)
90+
new.idx = len(self)
8191

82-
def span(self, raw: str) -> List[HistoryItem]:
83-
"""Parses the input string search for a span pattern and if if found, returns a slice from the History list.
92+
def get(self, index: Union[int, str]) -> HistoryItem:
93+
"""Get item from the History list using 1-based indexing.
8494
85-
:param raw: string potentially containing a span of the forms a..b, a:b, a:, ..b
86-
:return: slice from the History list
95+
:param index: optional item to get (index as either integer or string)
96+
:return: a single HistoryItem
8797
"""
88-
if raw.lower() in ('*', '-', 'all'):
89-
raw = ':'
90-
results = self.spanpattern.search(raw)
98+
index = int(index)
99+
if index == 0:
100+
raise IndexError('The first command in history is command 1.')
101+
elif index < 0:
102+
return self[index]
103+
else:
104+
return self[index - 1]
105+
106+
# This regular expression parses input for the span() method. There are five parts:
107+
#
108+
# ^\s* matches any whitespace at the beginning of the
109+
# input. This is here so you don't have to trim the input
110+
#
111+
# (?P<start>-?[1-9]{1}\d*)? create a capture group named 'start' which matches an
112+
# optional minus sign, followed by exactly one non-zero
113+
# digit, and as many other digits as you want. This group
114+
# is optional so that we can match an input string like '..2'.
115+
# This regex will match 1, -1, 10, -10, but not 0 or -0.
116+
#
117+
# (?P<separator>:|(\.{2,}))? create a capture group named 'separator' which matches either
118+
# a colon or two periods. This group is optional so we can
119+
# match a string like '3'
120+
#
121+
# (?P<end>-?[1-9]{1}\d*)? create a capture group named 'end' which matches an
122+
# optional minus sign, followed by exactly one non-zero
123+
# digit, and as many other digits as you want. This group is
124+
# optional so that we can match an input string like ':'
125+
# or '5:'. This regex will match 1, -1, 10, -10, but not
126+
# 0 or -0.
127+
#
128+
# \s*$ match any whitespace at the end of the input. This is here so
129+
# you don't have to trim the input
130+
#
131+
spanpattern = re.compile(r'^\s*(?P<start>-?[1-9]{1}\d*)?(?P<separator>:|(\.{2,}))?(?P<end>-?[1-9]{1}\d*)?\s*$')
132+
133+
def span(self, span: str) -> List[HistoryItem]:
134+
"""Return an index or slice of the History list,
135+
136+
:param raw: string containing an index or a slice
137+
:return: a list of HistoryItems
138+
139+
This method can accommodate input in any of these forms:
140+
141+
a
142+
-a
143+
a..b or a:b
144+
a.. or a:
145+
..a or :a
146+
-a.. or -a:
147+
..-a or :-a
148+
149+
Different from native python indexing and slicing of arrays, this method
150+
uses 1-based array numbering. Users who are not programmers can't grok
151+
0 based numbering. Programmers can usually grok either. Which reminds me,
152+
there are only two hard problems in programming:
153+
154+
- naming
155+
- cache invalidation
156+
- off by one errors
157+
158+
"""
159+
if span.lower() in ('*', '-', 'all'):
160+
span = ':'
161+
results = self.spanpattern.search(span)
91162
if not results:
92-
raise IndexError
93-
if not results.group('separator'):
94-
return [self[self._to_index(results.group('start'))]]
95-
start = self._to_index(results.group('start')) or 0 # Ensure start is not None
96-
end = self._to_index(results.group('end'))
97-
reverse = False
98-
if end is not None:
99-
if end < start:
100-
(start, end) = (end, start)
101-
reverse = True
102-
end += 1
103-
result = self[start:end]
104-
if reverse:
105-
result.reverse()
163+
# our regex doesn't match the input, bail out
164+
raise ValueError('History indices must be positive or negative integers, and may not be zero.')
165+
166+
sep = results.group('separator')
167+
start = results.group('start')
168+
if start:
169+
start = self._zero_based_index(start)
170+
end = results.group('end')
171+
if end:
172+
end = int(end)
173+
# modify end so it's inclusive of the last element
174+
if end == -1:
175+
# -1 as the end means include the last command in the array, which in pythonic
176+
# terms means to not provide an ending index. If you put -1 as the ending index
177+
# python excludes the last item in the list.
178+
end = None
179+
elif end < -1:
180+
# if the ending is smaller than -1, make it one larger so it includes
181+
# the element (python native indices exclude the last referenced element)
182+
end += 1
183+
184+
if start is not None and end is not None:
185+
# we have both start and end, return a slice of history
186+
result = self[start:end]
187+
elif start is not None and sep is not None:
188+
# take a slice of the array
189+
result = self[start:]
190+
elif end is not None and sep is not None:
191+
result = self[:end]
192+
elif start is not None:
193+
# there was no separator so it's either a posative or negative integer
194+
result = [self[start]]
195+
else:
196+
# we just have a separator, return the whole list
197+
result = self[:]
106198
return result
107199

108-
rangePattern = re.compile(r'^\s*(?P<start>[\d]+)?\s*-\s*(?P<end>[\d]+)?\s*$')
109-
110-
def append(self, new: Statement) -> None:
111-
"""Append a HistoryItem to end of the History list
200+
def str_search(self, search: str) -> List[HistoryItem]:
201+
"""Find history items which contain a given string
112202
113-
:param new: command line to convert to HistoryItem and add to the end of the History list
203+
:param search: the string to search for
204+
:return: a list of history items, or an empty list if the string was not found
114205
"""
115-
new = HistoryItem(new)
116-
list.append(self, new)
117-
new.idx = len(self)
206+
def isin(history_item):
207+
"""filter function for string search of history"""
208+
sloppy = utils.norm_fold(search)
209+
return sloppy in utils.norm_fold(history_item) or sloppy in utils.norm_fold(history_item.expanded)
210+
return [item for item in self if isin(item)]
118211

119-
def get(self, getme: Optional[Union[int, str]]=None) -> List[HistoryItem]:
120-
"""Get an item or items from the History list using 1-based indexing.
212+
def regex_search(self, regex: str) -> List[HistoryItem]:
213+
"""Find history items which match a given regular expression
121214
122-
:param getme: optional item(s) to get (either an integer index or string to search for)
123-
:return: list of HistoryItems matching the retrieval criteria
215+
:param regex: the regular expression to search for.
216+
:return: a list of history items, or an empty list if the string was not found
124217
"""
125-
if not getme:
126-
return self
127-
try:
128-
getme = int(getme)
129-
if getme < 0:
130-
return self[:(-1 * getme)]
131-
else:
132-
return [self[getme - 1]]
133-
except IndexError:
134-
return []
135-
except ValueError:
136-
range_result = self.rangePattern.search(getme)
137-
if range_result:
138-
start = range_result.group('start') or None
139-
end = range_result.group('start') or None
140-
if start:
141-
start = int(start) - 1
142-
if end:
143-
end = int(end)
144-
return self[start:end]
145-
146-
getme = getme.strip()
147-
148-
if getme.startswith(r'/') and getme.endswith(r'/'):
149-
finder = re.compile(getme[1:-1], re.DOTALL | re.MULTILINE | re.IGNORECASE)
150-
151-
def isin(hi):
152-
"""Listcomp filter function for doing a regular expression search of History.
153-
154-
:param hi: HistoryItem
155-
:return: bool - True if search matches
156-
"""
157-
return finder.search(hi) or finder.search(hi.expanded)
158-
else:
159-
def isin(hi):
160-
"""Listcomp filter function for doing a case-insensitive string search of History.
161-
162-
:param hi: HistoryItem
163-
:return: bool - True if search matches
164-
"""
165-
srch = utils.norm_fold(getme)
166-
return srch in utils.norm_fold(hi) or srch in utils.norm_fold(hi.expanded)
167-
return [itm for itm in self if isin(itm)]
218+
regex = regex.strip()
219+
if regex.startswith(r'/') and regex.endswith(r'/'):
220+
regex = regex[1:-1]
221+
finder = re.compile(regex, re.DOTALL | re.MULTILINE)
222+
223+
def isin(hi):
224+
"""filter function for doing a regular expression search of history"""
225+
return finder.search(hi) or finder.search(hi.expanded)
226+
return [itm for itm in self if isin(itm)]

0 commit comments

Comments
 (0)