Skip to content

Commit dfe5864

Browse files
committed
Clean up history command
1 parent d8ef258 commit dfe5864

File tree

3 files changed

+152
-110
lines changed

3 files changed

+152
-110
lines changed

cmd2/cmd2.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3213,15 +3213,22 @@ def do_history(self, args: argparse.Namespace) -> None:
32133213
# If a character indicating a slice is present, retrieve
32143214
# a slice of the history
32153215
arg = args.arg
3216+
arg_is_int = False
3217+
try:
3218+
_ = int(arg)
3219+
arg_is_int = True
3220+
except ValueError:
3221+
pass
3222+
32163223
if '..' in arg or ':' in arg:
3217-
try:
3218-
# Get a slice of history
3219-
history = self.history.span(arg)
3220-
except IndexError:
3221-
history = self.history.get(arg)
3224+
# Get a slice of history
3225+
history = self.history.span(arg)
3226+
elif arg_is_int:
3227+
history = [self.history.get(arg)]
3228+
elif arg.startswith(r'/') and arg.endswith(r'/'):
3229+
history = self.history.regex_search(arg)
32223230
else:
3223-
# Get item(s) from history by index or string search
3224-
history = self.history.get(arg)
3231+
history = self.history.str_search(arg)
32253232
else:
32263233
# If no arg given, then retrieve the entire history
32273234
cowardly_refuse_to_run = True

cmd2/history.py

Lines changed: 103 additions & 80 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
@@ -73,26 +73,62 @@ class History(list):
7373
"""
7474

7575
# noinspection PyMethodMayBeStatic
76-
def _zero_based_index(self, onebased: int) -> int:
76+
def _zero_based_index(self, onebased: Union[int, str]) -> int:
7777
"""Convert a one-based index to a zero-based index."""
78-
result = onebased
78+
result = int(onebased)
7979
if result > 0:
8080
result -= 1
8181
return result
8282

83-
def _to_index(self, raw: str) -> Optional[int]:
84-
if raw:
85-
result = self._zero_based_index(int(raw))
86-
else:
87-
result = None
88-
return result
83+
def append(self, new: Statement) -> None:
84+
"""Append a HistoryItem to end of the History list
8985
90-
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)
9191

92-
def span(self, raw: str) -> List[HistoryItem]:
93-
"""Parses the input string and return 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.
9494
95-
:param raw: string potentially containing a span
95+
:param index: optional item to get (index as either integer or string)
96+
:return: a single HistoryItem
97+
"""
98+
index = int(index)
99+
if index == 0:
100+
raise IndexError
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>-?\d+)? create a capture group named 'start' which matches one
112+
# or more digits, optionally preceeded by a minus sign. This
113+
# group is optional so that we can match a string like '..2'
114+
#
115+
# (?P<separator>:|(\.{2,}))? create a capture group named 'separator' which matches either
116+
# a colon or two periods. This group is optional so we can
117+
# match a string like '3'
118+
#
119+
# (?P<end>-?\d+)? create a capture group named 'end' which matches one or more
120+
# digits, optionally preceeded by a minus sign. This group is
121+
# optional so that we can match a string like ':' or '5:'
122+
#
123+
# \s*$ match any whitespace at the end of the input. This is here so
124+
# you don't have to trim the input
125+
#
126+
spanpattern = re.compile(r'^\s*(?P<start>-?\d+)?(?P<separator>:|(\.{2,}))?(?P<end>-?\d+)?\s*$')
127+
128+
def span(self, span: str) -> List[HistoryItem]:
129+
"""Return an index or slice of the History list,
130+
131+
:param raw: string containing an index or a slice
96132
:return: a list of HistoryItems
97133
98134
This method can accommodate input in any of these forms:
@@ -107,84 +143,71 @@ def span(self, raw: str) -> List[HistoryItem]:
107143
108144
Different from native python indexing and slicing of arrays, this method
109145
uses 1-based array numbering. Users who are not programmers can't grok
110-
0 based numbering. Programmers can grok either. Which reminds me, there
111-
are only two hard problems in programming:
146+
0 based numbering. Programmers can usually grok either. Which reminds me,
147+
there are only two hard problems in programming:
112148
113149
- naming
114150
- cache invalidation
115151
- off by one errors
116152
117153
"""
118-
if raw.lower() in ('*', '-', 'all'):
119-
raw = ':'
120-
results = self.spanpattern.search(raw)
154+
if span.lower() in ('*', '-', 'all'):
155+
span = ':'
156+
results = self.spanpattern.search(span)
121157
if not results:
122-
raise IndexError
123-
if not results.group('separator'):
124-
return [self[self._to_index(results.group('start'))]]
125-
start = self._to_index(results.group('start')) or 0 # Ensure start is not None
126-
end = self._to_index(results.group('end'))
127-
reverse = False
128-
if end is not None:
129-
if end < start:
130-
(start, end) = (end, start)
131-
reverse = True
132-
end += 1
133-
result = self[start:end]
134-
if reverse:
135-
result.reverse()
158+
# our regex doesn't match the input, bail out
159+
raise ValueError
160+
161+
sep = results.group('separator')
162+
start = results.group('start')
163+
if start:
164+
start = self._zero_based_index(start)
165+
end = results.group('end')
166+
if end:
167+
end = int(end)
168+
169+
if start is not None and end is not None:
170+
# we have both start and end, return a slice of history, unless both are negative
171+
if start < 0 and end < 0:
172+
raise ValueError
173+
result = self[start:end]
174+
elif start is not None and sep is not None:
175+
# take a slice of the array
176+
result = self[start:]
177+
elif end is not None and sep is not None:
178+
result = self[:end]
179+
elif start is not None:
180+
# there was no separator so it's either a posative or negative integer
181+
result = [self[start]]
182+
else:
183+
# we just have a separator, return the whole list
184+
result = self[:]
136185
return result
137186

138-
rangePattern = re.compile(r'^\s*(?P<start>[\d]+)?\s*-\s*(?P<end>[\d]+)?\s*$')
139-
140-
def append(self, new: Statement) -> None:
141-
"""Append a HistoryItem to end of the History list
142-
143-
:param new: command line to convert to HistoryItem and add to the end of the History list
144-
"""
145-
new = HistoryItem(new)
146-
list.append(self, new)
147-
new.idx = len(self)
148-
149-
def get(self, index: Union[int, str]) -> HistoryItem:
150-
"""Get item from the History list using 1-based indexing.
187+
def str_search(self, search: str) -> List[HistoryItem]:
188+
"""Find history items which contain a given string
151189
152-
:param index: optional item to get (index as either integer or string)
153-
:return: a single HistoryItem
190+
:param search: the string to search for
191+
:return: a list of history items, or an empty list if the string was not found
154192
"""
155-
index = int(index)
156-
if index == 0:
157-
raise IndexError
158-
elif index < 0:
159-
return self[index]
160-
else:
161-
return self[index - 1]
162-
163-
164-
165-
def str_search(self, search: str) -> List[HistoryItem]:
166-
pass
193+
def isin(history_item):
194+
"""filter function for string search of history"""
195+
sloppy = utils.norm_fold(search)
196+
return sloppy in utils.norm_fold(history_item) or sloppy in utils.norm_fold(history_item.expanded)
197+
return [item for item in self if isin(item)]
167198

168199
def regex_search(self, regex: str) -> List[HistoryItem]:
169-
regex = regex.strip()
170-
171-
if regex.startswith(r'/') and regex.endswith(r'/'):
172-
finder = re.compile(regex[1:-1], re.DOTALL | re.MULTILINE | re.IGNORECASE)
200+
"""Find history items which match a given regular expression
173201
174-
def isin(hi):
175-
"""Listcomp filter function for doing a regular expression search of History.
176-
177-
:param hi: HistoryItem
178-
:return: bool - True if search matches
179-
"""
180-
return finder.search(hi) or finder.search(hi.expanded)
181-
else:
182-
def isin(hi):
183-
"""Listcomp filter function for doing a case-insensitive string search of History.
184-
185-
:param hi: HistoryItem
186-
:return: bool - True if search matches
187-
"""
188-
srch = utils.norm_fold(regex)
189-
return srch in utils.norm_fold(hi) or srch in utils.norm_fold(hi.expanded)
190-
return [itm for itm in self if isin(itm)]
202+
:param regex: the regular expression to search for.
203+
:return: a list of history items, or an empty list if the string was not found
204+
"""
205+
regex = regex.strip()
206+
if regex.startswith(r'/') and regex.endswith(r'/'):
207+
regex = regex[1:-1]
208+
finder = re.compile(regex, re.DOTALL | re.MULTILINE)
209+
210+
def isin(hi):
211+
"""filter function for doing a regular expression search of history"""
212+
return finder.search(hi) or finder.search(hi.expanded)
213+
return [itm for itm in self if isin(itm)]

tests/test_history.py

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,28 @@ def hist():
6363
return h
6464

6565
def test_history_class_span(hist):
66-
h = hist
67-
assert h == ['first', 'second', 'third', 'fourth']
68-
assert h.span('-2..') == ['third', 'fourth']
69-
assert h.span('2..3') == ['second', 'third'] # Inclusive of end
70-
assert h.span('3') == ['third']
71-
assert h.span(':') == h
72-
assert h.span('2..') == ['second', 'third', 'fourth']
73-
assert h.span('-1') == ['fourth']
74-
assert h.span('-2..-3') == ['third', 'second']
75-
assert h.span('*') == h
66+
for tryit in ['*', ':', '-', 'all', 'ALL']:
67+
assert hist.span(tryit) == hist
68+
69+
assert hist.span('3') == ['third']
70+
assert hist.span('-1') == ['fourth']
71+
72+
assert hist.span('2..') == ['second', 'third', 'fourth']
73+
assert hist.span('2:') == ['second', 'third', 'fourth']
74+
75+
assert hist.span('-2..') == ['third', 'fourth']
76+
assert hist.span('-2:') == ['third', 'fourth']
77+
78+
assert hist.span('1..3') == ['first', 'second', 'third']
79+
assert hist.span('1:3') == ['first', 'second', 'third']
80+
81+
assert hist.span(':-2') == ['first', 'second']
82+
assert hist.span('..-2') == ['first', 'second']
83+
84+
value_errors = ['fred', 'fred:joe', 'a..b', '2 ..', '1 : 3', '-2..-3' ]
85+
for tryit in value_errors:
86+
with pytest.raises(ValueError):
87+
hist.span(tryit)
7688

7789
def test_history_class_get(hist):
7890
assert hist.get('1') == 'first'
@@ -101,10 +113,12 @@ def test_history_class_get(hist):
101113
hist.get(None)
102114

103115
def test_history_str_search(hist):
104-
assert hist.get('ir') == ['first', 'third']
116+
assert hist.str_search('ir') == ['first', 'third']
117+
assert hist.str_search('rth') == ['fourth']
105118

106119
def test_history_regex_search(hist):
107-
assert hist.get('/i.*d/') == ['third']
120+
assert hist.regex_search('/i.*d/') == ['third']
121+
assert hist.regex_search('s[a-z]+ond') == ['second']
108122

109123
def test_base_history(base_app):
110124
run_cmd(base_app, 'help')
@@ -222,11 +236,9 @@ def test_history_with_span_index_error(base_app):
222236
run_cmd(base_app, 'help')
223237
run_cmd(base_app, 'help history')
224238
run_cmd(base_app, '!ls -hal :')
225-
out = run_cmd(base_app, 'history "hal :"')
226-
expected = normalize("""
227-
3 !ls -hal :
228-
""")
229-
assert out == expected
239+
with pytest.raises(ValueError):
240+
_, err = run_cmd(base_app, 'history "hal :"')
241+
assert "ValueError" in err
230242

231243
def test_history_output_file(base_app):
232244
run_cmd(base_app, 'help')
@@ -380,8 +392,8 @@ def test_existing_history_file(hist_file, capsys):
380392
pass
381393

382394
# Create a new cmd2 app
383-
app = cmd2.Cmd(persistent_history_file=hist_file)
384-
out, err = capsys.readouterr()
395+
cmd2.Cmd(persistent_history_file=hist_file)
396+
_, err = capsys.readouterr()
385397

386398
# Make sure there were no errors
387399
assert err == ''
@@ -403,8 +415,8 @@ def test_new_history_file(hist_file, capsys):
403415
pass
404416

405417
# Create a new cmd2 app
406-
app = cmd2.Cmd(persistent_history_file=hist_file)
407-
out, err = capsys.readouterr()
418+
cmd2.Cmd(persistent_history_file=hist_file)
419+
_, err = capsys.readouterr()
408420

409421
# Make sure there were no errors
410422
assert err == ''
@@ -420,8 +432,8 @@ def test_bad_history_file_path(capsys, request):
420432
test_dir = os.path.dirname(request.module.__file__)
421433

422434
# Create a new cmd2 app
423-
app = cmd2.Cmd(persistent_history_file=test_dir)
424-
out, err = capsys.readouterr()
435+
cmd2.Cmd(persistent_history_file=test_dir)
436+
_, err = capsys.readouterr()
425437

426438
if sys.platform == 'win32':
427439
# pyreadline masks the read exception. Therefore the bad path error occurs when trying to write the file.

0 commit comments

Comments
 (0)