From 303e9cb734d217fe4f142040518e3dd7a2a2b3ee Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Thu, 8 Feb 2018 21:48:00 -0500 Subject: [PATCH 1/8] Added optional persistent readline history feature - Including an example and info in the Sphinx docs Also: - Created CHANGELOG entry for 0.8.1 release - Added info to README about new sub-menu feature - Bumped version to 0.8.1 TODO: - Added a unit test for the persistent readline history feature --- CHANGELOG.md | 9 +++++++++ README.md | 3 ++- cmd2.py | 28 +++++++++++++++++++++++----- docs/conf.py | 2 +- docs/freefeatures.rst | 15 +++++++++------ examples/persistent_history.py | 22 ++++++++++++++++++++++ examples/submenus.py | 6 +----- setup.py | 2 +- tests/test_cmd2.py | 2 +- 9 files changed, 69 insertions(+), 20 deletions(-) create mode 100755 examples/persistent_history.py mode change 100644 => 100755 examples/submenus.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ad98d0d0..110dec681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## 0.8.1 (TBD, 2018) + +* Enhancements + * Added support for sub-menus. + * See [submenus.py](https://github.com/python-cmd2/cmd2/blob/master/examples/submenus.py) for an example of how to use it + * Added option for persistent readline history + * See [persistent_history.py](https://github.com/python-cmd2/cmd2/blob/master/examples/persistent_history.py) for an example + * See the [Searchable command history](http://cmd2.readthedocs.io/en/latest/freefeatures.html#searchable-command-history) section of the documentation for more info + ## 0.8.0 (February 1, 2018) * Bug Fixes * Fixed unit tests on Python 3.7 due to changes in how re.escape() behaves in Python 3.7 diff --git a/README.md b/README.md index 94bc6c30a..85e26076f 100755 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ when using cmd. Main Features ------------- -- Searchable command history (`history` command and `+r`) +- Searchable command history (`history` command and `+r`) - optionally persistent - Text file scripting of your application with `load` (`@`) and `_relative_load` (`@@`) - Python scripting of your application with ``pyscript`` - Run shell commands with ``!`` @@ -30,6 +30,7 @@ Main Features - Special-character command shortcuts (beyond cmd's `@` and `!`) - Settable environment parameters - Parsing commands with arguments using `argparse`, including support for sub-commands +- Sub-menu support via the ``AddSubmenu`` decorator - Unicode character support (*Python 3 only*) - Good tab-completion of commands, sub-commands, file system paths, and shell commands - Python 2.7 and 3.4+ support diff --git a/cmd2.py b/cmd2.py index a237e1bd7..4f077b350 100755 --- a/cmd2.py +++ b/cmd2.py @@ -24,6 +24,8 @@ Git repository on GitHub at https://github.com/python-cmd2/cmd2 """ +import argparse +import atexit import cmd import codecs import collections @@ -31,7 +33,6 @@ import glob import io import optparse -import argparse import os import platform import re @@ -112,7 +113,7 @@ except ImportError: pass -__version__ = '0.8.0' +__version__ = '0.8.1' # Pyparsing enablePackrat() can greatly speed up parsing, but problems have been seen in Python 3 in the past pyparsing.ParserElement.enablePackrat() @@ -549,6 +550,7 @@ def strip_ansi(text): def _pop_readline_history(clear_history=True): """Returns a copy of readline's history and optionally clears it (default)""" + # noinspection PyArgumentList history = [ readline.get_history_item(i) for i in range(1, 1 + readline.get_current_history_length()) @@ -689,6 +691,7 @@ def enter_submenu(parent_cmd, line): ) submenu.cmdloop() if self.reformat_prompt is not None: + # noinspection PyUnboundLocalVariable self.submenu.prompt = prompt _push_readline_history(history) finally: @@ -761,12 +764,12 @@ class _Cmd(cmd_obj): _Cmd.complete_help = _complete_submenu_help # Create bindings in the parent command to the submenus commands. - setattr(_Cmd, 'do_' + self.command, enter_submenu) + setattr(_Cmd, 'do_' + self.command, enter_submenu) setattr(_Cmd, 'complete_' + self.command, complete_submenu) # Create additional bindings for aliases for _alias in self.aliases: - setattr(_Cmd, 'do_' + _alias, enter_submenu) + setattr(_Cmd, 'do_' + _alias, enter_submenu) setattr(_Cmd, 'complete_' + _alias, complete_submenu) return _Cmd @@ -833,12 +836,15 @@ class Cmd(cmd.Cmd): 'quiet': "Don't print nonessential feedback", 'timing': 'Report execution times'} - def __init__(self, completekey='tab', stdin=None, stdout=None, use_ipython=False, transcript_files=None): + def __init__(self, completekey='tab', stdin=None, stdout=None, persistent_history_file='', + persistent_history_length=1000, use_ipython=False, transcript_files=None): """An easy but powerful framework for writing line-oriented command interpreters, extends Python's cmd package. :param completekey: str - (optional) readline name of a completion key, default to Tab :param stdin: (optional) alternate input file object, if not specified, sys.stdin is used :param stdout: (optional) alternate output file object, if not specified, sys.stdout is used + :param persistent_history_file: str - (optional) file path to load a persistent readline history from + :param persistent_history_length: int - (optional) max number of lines which will be written to the history file :param use_ipython: (optional) should the "ipy" command be included for an embedded IPython shell :param transcript_files: str - (optional) allows running transcript tests when allow_cli_args is False """ @@ -849,6 +855,17 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, use_ipython=False except AttributeError: pass + # If persistent readline history is enabled, then read history from file and register to write to file at exit + if persistent_history_file: + persistent_history_file = os.path.expanduser(persistent_history_file) + try: + readline.read_history_file(persistent_history_file) + # default history len is -1 (infinite), which may grow unruly + readline.set_history_length(persistent_history_length) + except FileNotFoundError: + pass + atexit.register(readline.write_history_file, persistent_history_file) + # Call super class constructor. Need to do it in this way for Python 2 and 3 compatibility cmd.Cmd.__init__(self, completekey=completekey, stdin=stdin, stdout=stdout) @@ -2901,6 +2918,7 @@ def namedtuple_with_two_defaults(typename, field_names, default_values=('', '')) :return: namedtuple type """ T = collections.namedtuple(typename, field_names) + # noinspection PyUnresolvedReferences T.__new__.__defaults__ = default_values return T diff --git a/docs/conf.py b/docs/conf.py index d4ef14bf7..09d68b9ce 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -62,7 +62,7 @@ # The short X.Y version. version = '0.8' # The full version, including alpha/beta/rc tags. -release = '0.8.0' +release = '0.8.1' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index ea40c87c7..a439db565 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -13,7 +13,7 @@ Script files ============ Text files can serve as scripts for your ``cmd2``-based -application, with the ``load``, ``_relative_load``, ``edit`` and ``history`` commands. +application, with the ``load``, ``_relative_load``, and ``edit`` commands. Both ASCII and UTF-8 encoded unicode text files are supported. @@ -25,8 +25,6 @@ Simply include one command per line, typed exactly as you would inside a ``cmd2` .. automethod:: cmd2.Cmd.do_edit -.. automethod:: cmd2.Cmd.do_history - Comments ======== @@ -250,17 +248,22 @@ Searchable command history ========================== All cmd_-based applications have access to previous commands with -the up- and down- cursor keys. +the up- and down- arrow keys. All cmd_-based applications on systems with the ``readline`` module -also provide `bash-like history list editing`_. +also provide `Readline Emacs editing mode`_. With this you can, for example, use **Ctrl-r** to search backward through +the readline history. -.. _`bash-like history list editing`: http://www.talug.org/events/20030709/cmdline_history.html +``cmd2`` adds the option of making this readline history persistent via optional arguments to ``cmd2.Cmd.__init__()``: + +.. automethod:: cmd2.Cmd.__init__ ``cmd2`` makes a third type of history access available with the **history** command: .. automethod:: cmd2.Cmd.do_history +.. _`Readline Emacs editing mode`: http://readline.kablamo.org/emacs.html + Quitting the application ======================== diff --git a/examples/persistent_history.py b/examples/persistent_history.py new file mode 100755 index 000000000..21a2cbf34 --- /dev/null +++ b/examples/persistent_history.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# coding=utf-8 +"""This example demonstrates how to enable persistent readline history in your cmd2 application. + +This will allow end users of your cmd2-based application to use the arrow keys and Ctrl+r in a manner which persists +across invocations of your cmd2 application. This can make it much easier for them to use your application. +""" +import cmd2 + + +class Cmd2PersistentHistory(cmd2.Cmd): + """Basic example of how to enable persistent readline history within your cmd2 app.""" + def __init__(self): + """""" + cmd2.Cmd.__init__(self, persistent_history_file='~/.persistent_history.cmd2', persistent_history_length=500) + + # ... your class code here ... + + +if __name__ == '__main__': + app = Cmd2PersistentHistory() + app.cmdloop() diff --git a/examples/submenus.py b/examples/submenus.py old mode 100644 new mode 100755 index 52f26e08b..1e3da0dad --- a/examples/submenus.py +++ b/examples/submenus.py @@ -1,4 +1,5 @@ #!/usr/bin/env python +# coding=utf-8 """ Create a CLI with a nested command structure as follows. The commands 'second' and 'third' navigate the CLI to the scope of the submenu. Nesting of the submenus is done with the cmd2.AddSubmenu() decorator. @@ -6,9 +7,6 @@ (Top Level)----second----->(2nd Level)----third----->(3rd Level) | | | ---> say ---> say ---> say - - - """ from __future__ import print_function import sys @@ -71,7 +69,6 @@ def complete_say(self, text, line, begidx, endidx): return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - @cmd2.AddSubmenu(SecondLevel(), command='second', aliases=('second_alias',), @@ -105,7 +102,6 @@ def complete_say(self, text, line, begidx, endidx): return [s for s in ['qwe', 'asd', 'zxc'] if s.startswith(text)] - if __name__ == '__main__': root = TopLevel() diff --git a/setup.py b/setup.py index 64061ef05..ce8716274 100755 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ import sys from setuptools import setup -VERSION = '0.8.0' +VERSION = '0.8.1' DESCRIPTION = "cmd2 - a tool for building interactive command line applications in Python" LONG_DESCRIPTION = """cmd2 is a tool for building interactive command line applications in Python. Its goal is to make it quick and easy for developers to build feature-rich and user-friendly interactive command line applications. It diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 186def654..5f56803d3 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -25,7 +25,7 @@ def test_ver(): - assert cmd2.__version__ == '0.8.0' + assert cmd2.__version__ == '0.8.1' def test_empty_statement(base_app): From 6eb8dde0d33df2d73f9c7ff75c6beddae5453ddd Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 9 Feb 2018 00:07:06 -0500 Subject: [PATCH 2/8] First past at unit test for persistent history feature Added pexpect to modules required for running unit tests. This opens the door for carefully crafted complex unit tests to verify intricate behavior. Tests like this are somewhat painful to write and slow to execute. However, they can enable testing complicated interactive behavior that we otherwise probably would not be able to test. --- docs/requirements.txt | 1 + examples/persistent_history.py | 1 + setup.py | 2 +- tests/test_cmd2.py | 27 +++++++++++++++++++++++++++ tox.ini | 8 ++++++++ 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index fa4e35709..b50df7d1e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ pyparsing six pyperclip +contextlib2 diff --git a/examples/persistent_history.py b/examples/persistent_history.py index 21a2cbf34..e3f646bf0 100755 --- a/examples/persistent_history.py +++ b/examples/persistent_history.py @@ -13,6 +13,7 @@ class Cmd2PersistentHistory(cmd2.Cmd): def __init__(self): """""" cmd2.Cmd.__init__(self, persistent_history_file='~/.persistent_history.cmd2', persistent_history_length=500) + self.prompt = 'ph> ' # ... your class code here ... diff --git a/setup.py b/setup.py index ce8716274..80e8bd6a2 100755 --- a/setup.py +++ b/setup.py @@ -77,7 +77,7 @@ INSTALL_REQUIRES += ['subprocess32'] # unittest.mock was added in Python 3.3. mock is a backport of unittest.mock to all versions of Python -TESTS_REQUIRE = ['mock', 'pytest'] +TESTS_REQUIRE = ['mock', 'pytest', 'pexpect'] DOCS_REQUIRE = ['sphinx', 'sphinx_rtd_theme', 'pyparsing', 'pyperclip', 'six'] setup( diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 5f56803d3..454fdc1f5 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -11,6 +11,7 @@ import tempfile import mock +import pexpect import pytest import six @@ -1525,3 +1526,29 @@ def test_poutput_none(base_app): out = base_app.stdout.buffer expected = '' assert out == expected + + +def test_persistent_history(request): + test_dir = os.path.dirname(request.module.__file__) + persistent_app = os.path.join(test_dir, '..', 'examples', 'persistent_history.py') + + # STart an instance of the persistent history example and send it a few commands + child = pexpect.spawn(persistent_app) + prompt = 'ph> ' + child.expect(prompt) + child.sendline('help') + child.expect(prompt) + child.sendline('help history') + child.expect(prompt) + child.sendline('quit') + child.close() + + # Start a 2nd instance of the persistent history example and send it an up arrow to verify persistent history + up_arrow = '\x1b[A' + child2 = pexpect.spawn(persistent_app) + child2.expect(prompt) + child2.send(up_arrow) + child2.expect('quit', timeout=5) + assert child2.after == b'quit' + + diff --git a/tox.ini b/tox.ini index 366e4bc3f..2f5002ce5 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ setenv = deps = codecov mock + pexpect pyparsing pyperclip pytest @@ -28,6 +29,7 @@ commands = deps = codecov mock + pexpect pyparsing pyperclip pyreadline @@ -43,6 +45,7 @@ commands = [testenv:py34] deps = mock + pexpect pyparsing pyperclip pytest @@ -53,6 +56,7 @@ commands = py.test -v -n2 [testenv:py35] deps = mock + pexpect pyparsing pyperclip pytest @@ -63,6 +67,7 @@ commands = py.test -v -n2 [testenv:py35-win] deps = mock + pexpect pyparsing pyperclip pyreadline @@ -75,6 +80,7 @@ commands = py.test -v -n2 deps = codecov mock + pexpect pyparsing pyperclip pytest @@ -88,6 +94,7 @@ commands = [testenv:py36-win] deps = mock + pexpect pyparsing pyperclip pyreadline @@ -99,6 +106,7 @@ commands = py.test -v -n2 [testenv:py37] deps = mock + pexpect pyparsing pyperclip pytest From 8209a5ada836355148496a543f14179a545ee79c Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 9 Feb 2018 00:17:44 -0500 Subject: [PATCH 3/8] Try increasing timeout to see if it fixes unit test on at least some platforms --- tests/test_cmd2.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 454fdc1f5..28f133e61 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1532,7 +1532,7 @@ def test_persistent_history(request): test_dir = os.path.dirname(request.module.__file__) persistent_app = os.path.join(test_dir, '..', 'examples', 'persistent_history.py') - # STart an instance of the persistent history example and send it a few commands + # Start an instance of the persistent history example and send it a few commands child = pexpect.spawn(persistent_app) prompt = 'ph> ' child.expect(prompt) @@ -1548,7 +1548,7 @@ def test_persistent_history(request): child2 = pexpect.spawn(persistent_app) child2.expect(prompt) child2.send(up_arrow) - child2.expect('quit', timeout=5) + child2.expect('quit') assert child2.after == b'quit' From 7d4cbc4f2fcfe1cc2375146b572301478f1c1779 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 9 Feb 2018 00:41:29 -0500 Subject: [PATCH 4/8] Make sure pexpect uses same version of Python to spawn persistent history example --- examples/persistent_history.py | 18 ++++++++++++++---- tests/test_cmd2.py | 13 +++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/examples/persistent_history.py b/examples/persistent_history.py index e3f646bf0..e18742125 100755 --- a/examples/persistent_history.py +++ b/examples/persistent_history.py @@ -10,14 +10,24 @@ class Cmd2PersistentHistory(cmd2.Cmd): """Basic example of how to enable persistent readline history within your cmd2 app.""" - def __init__(self): - """""" - cmd2.Cmd.__init__(self, persistent_history_file='~/.persistent_history.cmd2', persistent_history_length=500) + def __init__(self, hist_file): + """Configure the app to load persistent readline history from a file. + + :param hist_file: file to load readline history from at start and write it to at end + """ + cmd2.Cmd.__init__(self, persistent_history_file=hist_file, persistent_history_length=500) + self.allow_cli_args = False self.prompt = 'ph> ' # ... your class code here ... if __name__ == '__main__': - app = Cmd2PersistentHistory() + import sys + + history_file = '~/.persistent_history.cmd2' + if len(sys.argv) > 1: + history_file = sys.argv[1] + + app = Cmd2PersistentHistory(hist_file=history_file) app.cmdloop() diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 28f133e61..618ac532a 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1532,8 +1532,14 @@ def test_persistent_history(request): test_dir = os.path.dirname(request.module.__file__) persistent_app = os.path.join(test_dir, '..', 'examples', 'persistent_history.py') + python = 'python3' + if six.PY2: + python = 'python2' + + command = '{} {}'.format(python, persistent_app) + # Start an instance of the persistent history example and send it a few commands - child = pexpect.spawn(persistent_app) + child = pexpect.spawn(command) prompt = 'ph> ' child.expect(prompt) child.sendline('help') @@ -1545,10 +1551,9 @@ def test_persistent_history(request): # Start a 2nd instance of the persistent history example and send it an up arrow to verify persistent history up_arrow = '\x1b[A' - child2 = pexpect.spawn(persistent_app) + child2 = pexpect.spawn(command) child2.expect(prompt) child2.send(up_arrow) child2.expect('quit') assert child2.after == b'quit' - - + child2.close() From d4cd3d033dc78624afed4d5419f14cece4ab586b Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 9 Feb 2018 00:47:56 -0500 Subject: [PATCH 5/8] Skip the pexpect-based unit test on Windows due to the pexpect API being different there --- tests/test_cmd2.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 618ac532a..9f8a40440 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1528,6 +1528,8 @@ def test_poutput_none(base_app): assert out == expected +@pytest.mark.skipif(sys.platform == 'win32', + reason="pexpect doesn't have a spawn() function on Windows") def test_persistent_history(request): test_dir = os.path.dirname(request.module.__file__) persistent_app = os.path.join(test_dir, '..', 'examples', 'persistent_history.py') From a20d8b862caf68359fc6728dc6d6fb78957c2b54 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 9 Feb 2018 00:54:53 -0500 Subject: [PATCH 6/8] Trying to manually add readline package to Travis-CI builds to see if that helps --- .travis.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.travis.yml b/.travis.yml index 2c5b5124d..c3287fc35 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,11 @@ language: python sudo: false # false enables container-based build for fast boot times on Linux +addons: + apt: + packages: + - readline + matrix: include: - os: linux From e4f14fb76050b7d063463080343cc3ca17cd0e49 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 9 Feb 2018 01:07:05 -0500 Subject: [PATCH 7/8] libreadline6 instead of readline --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index c3287fc35..f87a232bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ sudo: false # false enables container-based build for fast boot times on Linux addons: apt: packages: - - readline + - libreadline6 matrix: include: From f3c6b1b32d614076dc17d2736ae1860d37a36cd5 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Fri, 9 Feb 2018 01:17:33 -0500 Subject: [PATCH 8/8] Flag the pexpect unit test of the persistent history to only run on macOS - The Windows pexpect API is different from macOS or Linux - On Travis CI, the test behaves as if readline isn't working on those machines - The test runs fine when run on my system (on either macOS or Linux) --- .travis.yml | 5 ----- tests/test_cmd2.py | 5 +++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index f87a232bc..2c5b5124d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,11 +2,6 @@ language: python sudo: false # false enables container-based build for fast boot times on Linux -addons: - apt: - packages: - - libreadline6 - matrix: include: - os: linux diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 9f8a40440..ee9c1fc36 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1528,9 +1528,10 @@ def test_poutput_none(base_app): assert out == expected -@pytest.mark.skipif(sys.platform == 'win32', - reason="pexpect doesn't have a spawn() function on Windows") +@pytest.mark.skipif(sys.platform == 'win32' or sys.platform.startswith('lin'), + reason="pexpect doesn't have a spawn() function on Windows and readline doesn't work on TravisCI") def test_persistent_history(request): + """Will run on macOS to verify expected persistent history behavior.""" test_dir = os.path.dirname(request.module.__file__) persistent_app = os.path.join(test_dir, '..', 'examples', 'persistent_history.py')