Skip to content

Commit 297af34

Browse files
committed
Initializing history now detects plaintext or pickle format
1 parent d0add87 commit 297af34

File tree

4 files changed

+130
-54
lines changed

4 files changed

+130
-54
lines changed

CHANGELOG.md

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,25 @@
2323
* Added support for custom Namespaces in the argparse decorators. See description of `ns_provider` argument
2424
for more information.
2525
* Transcript testing now sets the `exit_code` returned from `cmdloop` based on Success/Failure
26-
* Potentially breaking changes
26+
* The history of entered commands previously was saved using the readline persistence mechanism,
27+
and only persisted if you had readline installed. Now history is persisted independent of readline; user
28+
input from previous invocations of `cmd2` based apps now shows in the `history` command.
29+
30+
* Breaking changes
2731
* Replaced `unquote_redirection_tokens()` with `unquote_specific_tokens()`. This was to support the fix
2832
that allows terminators in alias and macro values.
2933
* Changed `Statement.pipe_to` to a string instead of a list
3034
* `preserve_quotes` is now a keyword-only argument in the argparse decorators
3135
* Refactored so that `cmd2.Cmd.cmdloop()` returns the `exit_code` instead of a call to `sys.exit()`
3236
* It is now applicaiton developer's responsibility to treat the return value from `cmdloop()` accordingly
37+
, and is in a binary format,
38+
not a text format.
39+
* Only valid commands are persistent in history between invocations of `cmd2` based apps. Previously
40+
all user input was persistent in history. If readline is installed, the history available with the up and
41+
down arrow keys (readline history) may not match that shown in the `history` command, because `history`
42+
only tracks valid input, while readline history captures all input.
43+
* History is now persisted in a binary format, not plain text format. Previous history files are destroyed
44+
on first launch of a `cmd2` based app of version 0.9.13 or higher.
3345
* **Python 3.4 EOL notice**
3446
* Python 3.4 reached its [end of life](https://www.python.org/dev/peps/pep-0429/) on March 18, 2019
3547
* This is the last release of `cmd2` which will support Python 3.4
@@ -38,7 +50,7 @@
3850
* Bug Fixes
3951
* Fixed a bug in how redirection and piping worked inside ``py`` or ``pyscript`` commands
4052
* Fixed bug in `async_alert` where it didn't account for prompts that contained newline characters
41-
* Fixed path completion case when CWD is just a slash. Relative path matches were incorrectly prepended with a slash.
53+
* Fixed path completion case when CWD is just a slash. Relative path matches were incorrectly prepended with a slash.
4254
* Enhancements
4355
* Added ability to include command name placeholders in the message printed when trying to run a disabled command.
4456
* See docstring for ``disable_command()`` or ``disable_category()`` for more details.
@@ -60,7 +72,7 @@
6072
* ``_report_disabled_command_usage()`` - in all cases since this is called when a disabled command is run
6173
* Removed *** from beginning of error messages printed by `do_help()` and `default()`
6274
* Significantly refactored ``cmd.Cmd`` class so that all class attributes got converted to instance attributes, also:
63-
* Added ``allow_redirection``, ``terminators``, ``multiline_commands``, and ``shortcuts`` as optional arguments
75+
* Added ``allow_redirection``, ``terminators``, ``multiline_commands``, and ``shortcuts`` as optional arguments
6476
to ``cmd.Cmd.__init__()`
6577
* A few instance attributes were moved inside ``StatementParser`` and properties were created for accessing them
6678
* ``self.pipe_proc`` is now called ``self.cur_pipe_proc_reader`` and is a ``ProcReader`` class.
@@ -98,7 +110,7 @@
98110
``cmd2`` convention of setting ``self.matches_sorted`` to True before returning the results if you have already
99111
sorted the ``CompletionItem`` list. Otherwise it will be sorted using ``self.matches_sort_key``.
100112
* Removed support for bash completion since this feature had slow performance. Also it relied on
101-
``AutoCompleter`` which has since developed a dependency on ``cmd2`` methods.
113+
``AutoCompleter`` which has since developed a dependency on ``cmd2`` methods.
102114
* Removed ability to call commands in ``pyscript`` as if they were functions (e.g. ``app.help()``) in favor
103115
of only supporting one ``pyscript`` interface. This simplifies future maintenance.
104116
* No longer supporting C-style comments. Hash (#) is the only valid comment marker.
@@ -118,7 +130,7 @@
118130
* Fixed bug where the ``set`` command was not tab completing from the current ``settable`` dictionary.
119131
* Enhancements
120132
* Changed edit command to use do_shell() instead of calling os.system()
121-
133+
122134
## 0.9.8 (February 06, 2019)
123135
* Bug Fixes
124136
* Fixed issue with echoing strings in StdSim. Because they were being sent to a binary buffer, line buffering
@@ -139,9 +151,9 @@
139151
* Deletions (potentially breaking changes)
140152
* Deleted ``Cmd.colorize()`` and ``Cmd._colorcodes`` which were deprecated in 0.9.5
141153
* Replaced ``dir_exe_only`` and ``dir_only`` flags in ``path_complete`` with optional ``path_filter`` function
142-
that is used to filter paths out of completion results.
154+
that is used to filter paths out of completion results.
143155
* ``perror()`` no longer prepends "ERROR: " to the error message being printed
144-
156+
145157
## 0.9.6 (October 13, 2018)
146158
* Bug Fixes
147159
* Fixed bug introduced in 0.9.5 caused by backing up and restoring `self.prompt` in `pseudo_raw_input`.
@@ -167,8 +179,8 @@
167179
the argparse object. Also, single-character tokens that happen to be a
168180
prefix char are not treated as flags by argparse and AutoCompleter now
169181
matches that behavior.
170-
* Fixed bug where AutoCompleter was not distinguishing between a negative number and a flag
171-
* Fixed bug where AutoCompleter did not handle -- the same way argparse does (all args after -- are non-options)
182+
* Fixed bug where AutoCompleter was not distinguishing between a negative number and a flag
183+
* Fixed bug where AutoCompleter did not handle -- the same way argparse does (all args after -- are non-options)
172184
* Enhancements
173185
* Added ``exit_code`` attribute of ``cmd2.Cmd`` class
174186
* Enables applications to return a non-zero exit code when exiting from ``cmdloop``
@@ -180,10 +192,10 @@
180192
* These allow you to provide feedback to the user in an asychronous fashion, meaning alerts can
181193
display when the user is still entering text at the prompt. See [async_printing.py](https://github.com/python-cmd2/cmd2/blob/master/examples/async_printing.py)
182194
for an example.
183-
* Cross-platform colored output support
195+
* Cross-platform colored output support
184196
* ``colorama`` gets initialized properly in ``Cmd.__init()``
185197
* The ``Cmd.colors`` setting is no longer platform dependent and now has three values:
186-
* Terminal (default) - output methods do not strip any ANSI escape sequences when output is a terminal, but
198+
* Terminal (default) - output methods do not strip any ANSI escape sequences when output is a terminal, but
187199
if the output is a pipe or a file the escape sequences are stripped
188200
* Always - output methods **never** strip ANSI escape sequences, regardless of the output destination
189201
* Never - output methods strip all ANSI escape sequences
@@ -193,18 +205,18 @@
193205
* Deprecations
194206
* Deprecated the built-in ``cmd2`` support for colors including ``Cmd.colorize()`` and ``Cmd._colorcodes``
195207
* Deletions (potentially breaking changes)
196-
* The ``preparse``, ``postparsing_precmd``, and ``postparsing_postcmd`` methods *deprecated* in the previous release
208+
* The ``preparse``, ``postparsing_precmd``, and ``postparsing_postcmd`` methods *deprecated* in the previous release
197209
have been deleted
198210
* The new application lifecycle hook system allows for registration of callbacks to be called at various points
199211
in the lifecycle and is more powerful and flexible than the previous system
200212
* ``alias`` is now a command with sub-commands to create, list, and delete aliases. Therefore its syntax
201213
has changed. All current alias commands in startup scripts or transcripts will break with this release.
202214
* `unalias` was deleted since ``alias delete`` replaced it
203-
215+
204216
## 0.9.4 (August 21, 2018)
205217
* Bug Fixes
206218
* Fixed bug where ``preparse`` was not getting called
207-
* Fixed bug in parsing of multiline commands where matching quote is on another line
219+
* Fixed bug in parsing of multiline commands where matching quote is on another line
208220
* Enhancements
209221
* Improved implementation of lifecycle hooks to support a plugin
210222
framework, see ``docs/hooks.rst`` for details.

cmd2/cmd2.py

Lines changed: 50 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import glob
3535
import inspect
3636
import os
37+
import pickle
3738
import re
3839
import sys
3940
import threading
@@ -408,7 +409,6 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, persistent
408409
self.macros = dict()
409410

410411
self.initial_stdout = sys.stdout
411-
self.history = History()
412412
self.pystate = {}
413413
self.py_history = []
414414
self.pyscript_name = 'app'
@@ -463,37 +463,8 @@ def __init__(self, completekey: str = 'tab', stdin=None, stdout=None, persistent
463463
# If this string is non-empty, then this warning message will print if a broken pipe error occurs while printing
464464
self.broken_pipe_warning = ''
465465

466-
# Check if history should persist
467-
self.persistent_history_file = ''
468-
if persistent_history_file and rl_type != RlType.NONE:
469-
persistent_history_file = os.path.expanduser(persistent_history_file)
470-
read_err = False
471-
472-
try:
473-
# First try to read any existing history file
474-
readline.read_history_file(persistent_history_file)
475-
except FileNotFoundError:
476-
pass
477-
except OSError as ex:
478-
self.perror("readline cannot read persistent history file '{}': {}".format(persistent_history_file, ex),
479-
traceback_war=False)
480-
read_err = True
481-
482-
if not read_err:
483-
try:
484-
# Make sure readline is able to write the history file. Doing it this way is a more thorough check
485-
# than trying to open the file with write access since readline's underlying function needs to
486-
# create a temporary file in the same directory and may not have permission.
487-
readline.set_history_length(persistent_history_length)
488-
readline.write_history_file(persistent_history_file)
489-
except OSError as ex:
490-
self.perror("readline cannot write persistent history file '{}': {}".
491-
format(persistent_history_file, ex), traceback_war=False)
492-
else:
493-
# Set history file and register to save our history at exit
494-
import atexit
495-
self.persistent_history_file = persistent_history_file
496-
atexit.register(readline.write_history_file, self.persistent_history_file)
466+
# initialize history
467+
self._initialize_history(persistent_history_file, persistent_history_length)
497468

498469
# If a startup script is provided, then add it in the queue to load
499470
if startup_script is not None:
@@ -3476,6 +3447,53 @@ def do_history(self, args: argparse.Namespace) -> None:
34763447
for hi in history:
34773448
self.poutput(hi.pr(script=args.script, expanded=args.expanded, verbose=args.verbose))
34783449

3450+
def _initialize_history(self, histfile, maxlen):
3451+
"""Initialize history with optional persistence and maximum length
3452+
3453+
This function can determine whether history is saved in the prior text-based
3454+
format (one line of input is stored as one line in the file), or the new-as-
3455+
of-version 0.9.13 pickle based format.
3456+
3457+
History created by versions <= 0.9.12 is in readline format, i.e. plain text files.
3458+
3459+
Initializing history does not effect history files on disk, versions >= 0.9.13 always
3460+
write history in the pickle format.
3461+
"""
3462+
self.history = History()
3463+
# with no persistent history, nothing else in this method is relevant
3464+
if not histfile:
3465+
return
3466+
3467+
histfile = os.path.expanduser(histfile)
3468+
3469+
# first we try and unpickle the history file
3470+
history = History()
3471+
try:
3472+
with open(histfile, 'rb') as fobj:
3473+
history = pickle.load(fobj)
3474+
except (FileNotFoundError, KeyError, EOFError):
3475+
pass
3476+
except OSError as ex:
3477+
msg = "can not read persistent history file '{}': {}"
3478+
self.perror(msg.format(histfile, ex), traceback_war=False)
3479+
3480+
# trim history to length and ensure it's writable
3481+
history.truncate(maxlen)
3482+
try:
3483+
# open with append so it doesn't truncate the file
3484+
with open(histfile, 'ab') as fobj:
3485+
self.persistent_history_file = histfile
3486+
except OSError as ex:
3487+
msg = "can not write persistent history file '{}': {}"
3488+
self.perror(msg.format(histfile, ex), traceback_war=False)
3489+
3490+
# register a function to write history at save
3491+
# if the history file is in plain text format from 0.9.12 or lower
3492+
# this will fail, and the history in the plain text file will be lost
3493+
3494+
#import atexit
3495+
#atexit.register(readline.write_history_file, self.persistent_history_file)
3496+
34793497
def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcript_file: str) -> None:
34803498
"""Generate a transcript file from a given history of commands."""
34813499
import io

cmd2/history.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,17 @@ def isin(hi):
224224
"""filter function for doing a regular expression search of history"""
225225
return finder.search(hi) or finder.search(hi.expanded)
226226
return [itm for itm in self if isin(itm)]
227+
228+
def truncate(self, max_length:int) -> None:
229+
"""Truncate the length of the history, dropping the oldest items if necessary
230+
231+
:param max_length: the maximum length of the history, if negative, all history
232+
items will be deleted
233+
:return: nothing
234+
"""
235+
if max_length <= 0:
236+
# remove all history
237+
del self[:]
238+
elif len(self) > max_length:
239+
last_element = len(self) - max_length
240+
del self[0:last_element]

tests/test_history.py

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66
import tempfile
77
import os
8+
import pickle
89
import sys
910

1011
import pytest
@@ -121,6 +122,19 @@ def test_history_regex_search(hist):
121122
assert hist.regex_search('/i.*d/') == ['third']
122123
assert hist.regex_search('s[a-z]+ond') == ['second']
123124

125+
def test_history_max_length_zero(hist):
126+
hist.truncate(0)
127+
assert len(hist) == 0
128+
129+
def test_history_max_length_negative(hist):
130+
hist.truncate(-1)
131+
assert len(hist) == 0
132+
133+
def test_history_max_length(hist):
134+
hist.truncate(2)
135+
assert hist.get(1) == 'third'
136+
assert hist.get(2) == 'fourth'
137+
124138
def test_base_history(base_app):
125139
run_cmd(base_app, 'help')
126140
run_cmd(base_app, 'shortcuts')
@@ -399,7 +413,7 @@ def test_existing_history_file(hist_file, capsys):
399413
assert err == ''
400414

401415
# Unregister the call to write_history_file that cmd2 did
402-
atexit.unregister(readline.write_history_file)
416+
## TODO atexit.unregister(readline.write_history_file)
403417

404418
# Remove created history file
405419
os.remove(hist_file)
@@ -422,7 +436,7 @@ def test_new_history_file(hist_file, capsys):
422436
assert err == ''
423437

424438
# Unregister the call to write_history_file that cmd2 did
425-
atexit.unregister(readline.write_history_file)
439+
### TODO atexit.unregister(readline.write_history_file)
426440

427441
# Remove created history file
428442
os.remove(hist_file)
@@ -435,10 +449,28 @@ def test_bad_history_file_path(capsys, request):
435449
cmd2.Cmd(persistent_history_file=test_dir)
436450
_, err = capsys.readouterr()
437451

438-
if sys.platform == 'win32':
439-
# pyreadline masks the read exception. Therefore the bad path error occurs when trying to write the file.
440-
assert 'readline cannot write' in err
441-
else:
442-
# GNU readline raises an exception upon trying to read the directory as a file
443-
assert 'readline cannot read' in err
452+
assert 'can not write' in err
453+
454+
def test_history_file_conversion_no_truncate_on_init(hist_file, capsys):
455+
# test the code that converts a plain text history file to a pickle binary
456+
# history file
457+
458+
# first we need some plain text commands in the history file
459+
with open(hist_file, 'w') as hfobj:
460+
hfobj.write('help\n')
461+
hfobj.write('alias\n')
462+
hfobj.write('alias create s shortcuts\n')
463+
464+
# Create a new cmd2 app
465+
cmd2.Cmd(persistent_history_file=hist_file)
466+
467+
# history should be initialized, but the file on disk should
468+
# still be plain text
469+
with open(hist_file, 'r') as hfobj:
470+
histlist= hfobj.readlines()
444471

472+
assert len(histlist) == 3
473+
# history.get() is overridden to be one based, not zero based
474+
assert histlist[0]== 'help\n'
475+
assert histlist[1] == 'alias\n'
476+
assert histlist[2] == 'alias create s shortcuts\n'

0 commit comments

Comments
 (0)