Skip to content

Commit b94d04a

Browse files
committed
Converted persistent history files from pickle to JSON format
1 parent 5700c57 commit b94d04a

File tree

4 files changed

+111
-30
lines changed

4 files changed

+111
-30
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.1.0 (TBD, 2021)
2+
* Enhancements
3+
* Converted persistent history files from pickle to JSON format
4+
15
## 2.0.1 (June 7, 2021)
26
* Bug Fixes
37
* Exclude `plugins` and `tests_isolated` directories from tarball published to PyPI for `cmd2` release

cmd2/cmd2.py

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@
3434
import glob
3535
import inspect
3636
import os
37-
import pickle
3837
import pydoc
3938
import re
4039
import sys
@@ -4443,15 +4442,14 @@ def _get_history(self, args: argparse.Namespace) -> 'OrderedDict[int, HistoryIte
44434442
def _initialize_history(self, hist_file: str) -> None:
44444443
"""Initialize history using history related attributes
44454444
4446-
This function can determine whether history is saved in the prior text-based
4447-
format (one line of input is stored as one line in the file), or the new-as-
4448-
of-version 0.9.13 pickle based format.
4449-
4450-
History created by versions <= 0.9.12 is in readline format, i.e. plain text files.
4451-
4452-
Initializing history does not effect history files on disk, versions >= 0.9.13 always
4453-
write history in the pickle format.
4445+
:param hist_file: optional path to persistent history file. If specified, then history from
4446+
previous sessions will be included. Additionally, all history will be written
4447+
to this file when the application exits.
44544448
"""
4449+
from json import (
4450+
JSONDecodeError,
4451+
)
4452+
44554453
self.history = History()
44564454
# with no persistent history, nothing else in this method is relevant
44574455
if not hist_file:
@@ -4474,36 +4472,27 @@ def _initialize_history(self, hist_file: str) -> None:
44744472
self.perror(f"Error creating persistent history file directory '{hist_file_dir}': {ex}")
44754473
return
44764474

4477-
# first we try and unpickle the history file
4478-
history = History()
4479-
4475+
# Read and process history file
44804476
try:
4481-
with open(hist_file, 'rb') as fobj:
4482-
history = pickle.load(fobj)
4483-
except (
4484-
AttributeError,
4485-
EOFError,
4486-
FileNotFoundError,
4487-
ImportError,
4488-
IndexError,
4489-
KeyError,
4490-
ValueError,
4491-
pickle.UnpicklingError,
4492-
):
4493-
# If any of these errors occur when attempting to unpickle, just use an empty history
4477+
with open(hist_file, 'r') as fobj:
4478+
history_json = fobj.read()
4479+
self.history = History.from_json(history_json)
4480+
except FileNotFoundError:
4481+
# Just use an empty history
44944482
pass
44954483
except OSError as ex:
44964484
self.perror(f"Cannot read persistent history file '{hist_file}': {ex}")
44974485
return
4486+
except (JSONDecodeError, KeyError, ValueError) as ex:
4487+
self.perror(f"Error processing persistent history file '{hist_file}': {ex}")
44984488

4499-
self.history = history
45004489
self.history.start_session()
45014490
self.persistent_history_file = hist_file
45024491

45034492
# populate readline history
45044493
if rl_type != RlType.NONE:
45054494
last = None
4506-
for item in history:
4495+
for item in self.history:
45074496
# Break the command into its individual lines
45084497
for line in item.raw.splitlines():
45094498
# readline only adds a single entry for multiple sequential identical lines
@@ -4520,14 +4509,14 @@ def _initialize_history(self, hist_file: str) -> None:
45204509
atexit.register(self._persist_history)
45214510

45224511
def _persist_history(self) -> None:
4523-
"""Write history out to the history file"""
4512+
"""Write history out to the persistent history file as JSON"""
45244513
if not self.persistent_history_file:
45254514
return
45264515

45274516
self.history.truncate(self._persistent_history_length)
45284517
try:
4529-
with open(self.persistent_history_file, 'wb') as fobj:
4530-
pickle.dump(self.history, fobj)
4518+
with open(self.persistent_history_file, 'w') as fobj:
4519+
fobj.write(self.history.to_json())
45314520
except OSError as ex:
45324521
self.perror(f"Cannot write persistent history file '{self.persistent_history_file}': {ex}")
45334522

cmd2/history.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
History management classes
44
"""
55

6+
import json
67
import re
78
from collections import (
89
OrderedDict,
910
)
1011
from typing import (
12+
Any,
1113
Callable,
14+
Dict,
1215
Iterable,
1316
List,
1417
Optional,
@@ -33,6 +36,9 @@ class HistoryItem:
3336
_listformat = ' {:>4} {}'
3437
_ex_listformat = ' {:>4}x {}'
3538

39+
# Used in JSON dictionaries
40+
_statement_field = 'statement'
41+
3642
statement: Statement = attr.ib(default=None, validator=attr.validators.instance_of(Statement))
3743

3844
def __str__(self) -> str:
@@ -94,6 +100,22 @@ def pr(self, idx: int, script: bool = False, expanded: bool = False, verbose: bo
94100

95101
return ret_str
96102

103+
def to_dict(self) -> Dict[str, Any]:
104+
"""Utility method to convert this HistoryItem into a dictionary for use in persistent JSON history files"""
105+
return {HistoryItem._statement_field: self.statement.to_dict()}
106+
107+
@staticmethod
108+
def from_dict(source_dict: Dict[str, Any]) -> 'HistoryItem':
109+
"""
110+
Utility method to restore a HistoryItem from a dictionary
111+
112+
:param source_dict: source data dictionary (generated using to_dict())
113+
:return: HistoryItem object
114+
:raises KeyError: if source_dict is missing required elements
115+
"""
116+
statement_dict = source_dict[HistoryItem._statement_field]
117+
return HistoryItem(Statement.from_dict(statement_dict))
118+
97119

98120
class History(List[HistoryItem]):
99121
"""A list of :class:`~cmd2.history.HistoryItem` objects with additional methods
@@ -109,6 +131,11 @@ class History(List[HistoryItem]):
109131
class to gain access to the historical record.
110132
"""
111133

134+
# Used in JSON dictionaries
135+
_history_version = '1.0.0'
136+
_history_version_field = 'history_version'
137+
_history_items_field = 'history_items'
138+
112139
def __init__(self, seq: Iterable[HistoryItem] = ()) -> None:
113140
super(History, self).__init__(seq)
114141
self.session_start_index = 0
@@ -301,3 +328,36 @@ def _build_result_dictionary(
301328
if filter_func is None or filter_func(self[index]):
302329
results[index + 1] = self[index]
303330
return results
331+
332+
def to_json(self) -> str:
333+
"""Utility method to convert this History into a JSON string for use in persistent history files"""
334+
json_dict = {
335+
History._history_version_field: History._history_version,
336+
History._history_items_field: [hi.to_dict() for hi in self],
337+
}
338+
return json.dumps(json_dict, ensure_ascii=False, indent=2)
339+
340+
@staticmethod
341+
def from_json(history_json: str) -> 'History':
342+
"""
343+
Utility method to restore History from a JSON string
344+
345+
:param history_json: history data as JSON string (generated using to_json())
346+
:return: History object
347+
:raises json.JSONDecodeError: if passed invalid JSON string
348+
:raises KeyError: if JSON is missing required elements
349+
:raises ValueError: if history version in JSON isn't supported
350+
"""
351+
json_dict = json.loads(history_json)
352+
version = json_dict[History._history_version_field]
353+
if version != History._history_version:
354+
raise ValueError(
355+
f"Unsupported history file version: {version}. This application uses version {History._history_version}."
356+
)
357+
358+
items = json_dict[History._history_items_field]
359+
history = History()
360+
for hi_dict in items:
361+
history.append(HistoryItem.from_dict(hi_dict))
362+
363+
return history

cmd2/parsing.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,9 @@ class Statement(str): # type: ignore[override]
147147
# if output was redirected, the destination file token (quotes preserved)
148148
output_to: str = attr.ib(default='', validator=attr.validators.instance_of(str))
149149

150+
# Used in JSON dictionaries
151+
_args_field = 'args'
152+
150153
def __new__(cls, value: object, *pos_args: Any, **kw_args: Any) -> 'Statement':
151154
"""Create a new instance of Statement.
152155
@@ -221,6 +224,31 @@ def argv(self) -> List[str]:
221224

222225
return rtn
223226

227+
def to_dict(self) -> Dict[str, Any]:
228+
"""Utility method to convert this Statement into a dictionary for use in persistent JSON history files"""
229+
return self.__dict__.copy()
230+
231+
@staticmethod
232+
def from_dict(source_dict: Dict[str, Any]) -> 'Statement':
233+
"""
234+
Utility method to restore a Statement from a dictionary
235+
236+
:param source_dict: source data dictionary (generated using to_dict())
237+
:return: Statement object
238+
:raises KeyError: if source_dict is missing required elements
239+
"""
240+
# value needs to be passed as a positional argument. It corresponds to the args field.
241+
try:
242+
value = source_dict[Statement._args_field]
243+
except KeyError as ex:
244+
raise KeyError(f"Statement dictionary is missing {ex} field")
245+
246+
# Pass the rest at kwargs (minus args)
247+
kwargs = source_dict.copy()
248+
del kwargs[Statement._args_field]
249+
250+
return Statement(value, **kwargs)
251+
224252

225253
class StatementParser:
226254
"""Parse user input as a string into discrete command components."""

0 commit comments

Comments
 (0)