changeset 8439:3bdae15252c6

feat: add support for ! history and readline command in roundup-admin Ad support to change input mode emacs/vi using new 'readline' roundup-admin command. Also bind keys to command/input strings, List numbered history and allow rerunning a command with !<number> or allow user to edit it using !<number>:p. admin_guide.txt: Added docs. admin.py: add functionality. Reconcile import commands to standard. Replace IOError with FileNotFoundError no that we have removed python 2.7 support. Add support for identifying backend used to supply line editing/history functions. Add support for saving commands sent on stdin to history to allow preloading of history. test_admin.py: Test code. Can't test mode changes as lack of pty when driving command line turns off line editing in readline/pyreadline3. Similarly can't test key bindings/settings. Some refactoring of test conditions that had to change because of additional output reporting backend library.
author John Rouillard <rouilj@ieee.org>
date Sun, 31 Aug 2025 16:54:17 -0400
parents 98e17dd0197f
children 254f70dfc585
files CHANGES.txt doc/admin_guide.txt roundup/admin.py test/test_admin.py
diffstat 4 files changed, 586 insertions(+), 23 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES.txt	Thu Aug 28 12:39:38 2025 -0400
+++ b/CHANGES.txt	Sun Aug 31 16:54:17 2025 -0400
@@ -36,6 +36,9 @@
   style configs will still be supported. (John Rouillard)
 - add 'q' as alias for quit in roundup-admin interactive mode. (John
   Rouillard)
+- add readline command to roundup-admin to list history, control input
+  mode etc. Also support bang (!) commands to rerun commands in history
+  or put them in the input buffer for editing. (John Rouillard)
 
 2025-07-13 2.5.0
 
--- a/doc/admin_guide.txt	Thu Aug 28 12:39:38 2025 -0400
+++ b/doc/admin_guide.txt	Sun Aug 31 16:54:17 2025 -0400
@@ -2202,6 +2202,57 @@
 History is saved to the file ``.roundup_admin_history`` in your home
 directory (for windows usually ``\Users\<username>``.
 
+In Roundup 2.6.0 and newer, you can use the ``readline`` command to
+make changes on the fly.
+
+* ``readline vi`` - change input mode to use vi key binding when
+  editing. It starts in entry mode.
+* ``readline emacs`` - change input mode to emacs key bindings when
+  editing. This is also the default.
+* ``readline reload`` - reloads the ``~/.roundup_admin_rlrc`` file so
+  you can test and use changes.
+* ``readline history`` - dumps the history buffer and numbers all
+  commands.
+* ``readline .inputrc_command_line`` can be used to make on the fly
+  key and key sequence bindings to readline commands. It can also be
+  used to change the internal readline settings using a set
+  command. For example::
+
+    readline set bell-style none
+
+  will turn off a ``visible`` or ``audible`` bell. Single character
+  keybindings::
+
+    readline Control-o: dump-variables
+
+  to list all the variables that can be set are supported. As are
+  multi-character bindings::
+
+    readline "\C-o1": "commit"
+
+  will put "commit" on the input line when you type Control-o followed
+  by 1. See the `readline manual for details
+  <https://tiswww.cwru.edu/php/chet/readline/rluserman.html#Readline-Init-File-Syntax-1>`_
+  on the command lines that can be used.
+
+Also a limited form of ``!`` (bang) history reference was added. The
+reference must be at the start of the line. Typing ``!23`` will rerun
+command number 23 from your history.
+
+Typing ``!23:p`` will load command 23 into the buffer so you can edit
+and submit it. Using the bang feature will append the command to the
+end of the history list.
+
+Pyreadline3 users can use ``readline history`` and the
+bang commands (including ``:p``). Single character bindings can be
+done. For example::
+
+    readline Control-w: history-search-backward
+
+The commands that are available are limited compared to Unix's
+readline or libedit. Setting variables or entry mode (emacs,
+vi) switching do not work in testing.
+
 Using with the shell
 --------------------
 
--- a/roundup/admin.py	Thu Aug 28 12:39:38 2025 -0400
+++ b/roundup/admin.py	Sun Aug 31 16:54:17 2025 -0400
@@ -34,8 +34,8 @@
 
 import roundup.instance
 from roundup import __version__ as roundup_version
-from roundup import date, hyperdb, init, password, token_r
-from roundup.anypy import scandir_
+from roundup import date, hyperdb, init, password, support, token_r
+from roundup.anypy import scandir_  # noqa: F401 define os.scandir
 from roundup.anypy.my_input import my_input
 from roundup.anypy.strings import repr_export
 from roundup.configuration import (
@@ -49,7 +49,6 @@
 )
 from roundup.exceptions import UsageError
 from roundup.i18n import _, get_translation
-from roundup import support
 
 try:
     from UserDict import UserDict
@@ -93,6 +92,13 @@
         Additional help may be supplied by help_*() methods.
     """
 
+    # import here to make AdminTool.readline accessible or
+    # mockable from tests.
+    try:
+        import readline   # noqa: I001, PLC0415
+    except ImportError:
+        readline = None
+
     # Make my_input a property to allow overriding in testing.
     # my_input is imported in other places, so just set it from
     # the imported value rather than moving def here.
@@ -1815,6 +1821,111 @@
                              type(self.settings[setting]).__name__)
         self.settings[setting] = value
 
+    def do_readline(self, args):
+        ''"""Usage: readline initrc_line | 'emacs' | 'history' | 'reload' | 'vi'
+
+        Using 'reload' will reload the file ~/.roundup_admin_rlrc.
+        'history' will show (and number) all commands in the history.
+
+        You can change input mode using the 'emacs' or 'vi' parameters.
+        The default is emacs. This is the same as using::
+
+           readline set editing-mode emacs
+
+        or::
+
+           readline set editing-mode vi
+
+        Any command that can be placed in a readline .inputrc file can
+        be executed using the readline command. You can assign
+        dump-variables to control O using::
+
+           readline Control-o: dump-variables
+
+        Assigning multi-key values also works.
+
+        pyreadline3 support on windows:
+
+          Mode switching doesn't work, emacs only.
+
+          Binding single key commands works with::
+
+            readline Control-w: history-search-backward
+
+          Multiple key sequences don't work.
+
+          Setting values may work. Difficult to tell because the library
+          has no way to view the live settings.
+
+        """
+
+        # TODO: allow history 20 # most recent 20 commands
+        #    history 100-200 # show commands 100-200
+
+        if not self.readline:
+            print(_("Readline support is not available."))
+            return
+        # The if test allows pyreadline3 settings like:
+        #   bind_exit_key("Control-z") get through to
+        #   parse_and_bind(). It is not obvious that this form of
+        #   command is supported. Pyreadline3 is supposed to parse
+        #   readline style commands, so we use those for emacs/vi.
+        #   Trying set-mode(...) as in the pyreadline3 init file
+        #   didn't work in testing.
+
+        if len(args) == 1 and args[0].find('(') == -1:
+            if args[0] == "vi":
+                self.readline.parse_and_bind("set editing-mode vi")
+                print(_("Enabled vi mode."))
+            elif args[0] == "emacs":
+                self.readline.parse_and_bind("set editing-mode emacs")
+                print(_("Enabled emacs mode."))
+            elif args[0] == "history":
+                print("history size",
+                      self.readline.get_current_history_length())
+                print('\n'.join([
+                    str("%3d " % (i + 1) +
+                        self.readline.get_history_item(i + 1))
+                    for i in range(
+                            self.readline.get_current_history_length())
+                ]))
+            elif args[0] == "reload":
+                try:
+                    # readline is a singleton. In testing previous
+                    # tests using read_init_file are loading from ~
+                    # not the test directory because it doesn't
+                    # matter.  But for reload we want to test with the
+                    # init file under the test directory. Calling
+                    # read_init_file() calls with the ~/.. init
+                    # location and I can't seem to reset it
+                    # or the readline state.
+                    # So call with explicit file here.
+                    self.readline.read_init_file(
+                        self.get_readline_init_file())
+                except FileNotFoundError as e:
+                    # If user invoked reload explicitly, report
+                    # if file not found.
+                    #
+                    # DOES NOT WORK with pyreadline3. Exception
+                    # is not raised if file is missing.
+                    #
+                    # Also e.filename is None under cygwin. A
+                    # simple test case does set e.filename
+                    # correctly?? sigh.  So I just call
+                    # get_readline_init_file again to get
+                    # filename.
+                    fn = e.filename or self.get_readline_init_file()
+                    print(_("Init file %s not found.") % fn)
+                else:
+                    print(_("File %s reloaded.") %
+                          self.get_readline_init_file())
+            else:
+                print(_("Unknown readline parameter %s") % args[0])
+            return
+
+        self.readline.parse_and_bind(" ".join(args))
+        return
+
     designator_re = re.compile('([A-Za-z]+)([0-9]+)$')
     designator_rng = re.compile('([A-Za-z]+):([0-9]+)-([0-9]+)$')
 
@@ -2365,29 +2476,34 @@
         # setting the bit disables the feature, so use not.
         return not self.settings['history_features'] & features[feature]
 
+    def get_readline_init_file(self):
+        return os.path.join(os.path.expanduser("~"),
+                                ".roundup_admin_rlrc")
+
     def interactive(self):
         """Run in an interactive mode
         """
         print(_('Roundup %s ready for input.\nType "help" for help.')
                 % roundup_version)
 
-        initfile = os.path.join(os.path.expanduser("~"),
-                                ".roundup_admin_rlrc")
+        initfile = self.get_readline_init_file()
         histfile = os.path.join(os.path.expanduser("~"),
                                 ".roundup_admin_history")
 
-        try:
-            import readline
+        if self.readline:
+            # clear any history that might be left over from caller
+            # when reusing AdminTool from tests or program.
+            self.readline.clear_history()
             try:
                 if self.history_features('load_rc'):
-                    readline.read_init_file(initfile)
-            except IOError: # FileNotFoundError under python3
+                    self.readline.read_init_file(initfile)
+            except FileNotFoundError:
                 # file is optional
                 pass
 
             try:
                 if self.history_features('load_history'):
-                    readline.read_history_file(histfile)
+                    self.readline.read_history_file(histfile)
             except IOError:  # FileNotFoundError under python3
                 # no history file yet
                 pass
@@ -2397,19 +2513,75 @@
             # Pragma history_length allows setting on a per
             #   invocation basis at startup
             if self.settings['history_length'] != -1:
-                readline.set_history_length(
+                self.readline.set_history_length(
                     self.settings['history_length'])
-        except ImportError:
-            readline = None
-            print(_('Note: command history and editing not available'))
 
+            if hasattr(self.readline, 'backend'):
+                # FIXME after min 3.13 version; no backend prints pyreadline3
+                print(_("Readline enabled using %s.") % self.readline.backend)
+            else:
+                print(_("Readline enabled using unknown library."))
+                      
+        else:
+            print(_('Command history and line editing not available'))
+
+        autosave_enabled = sys.stdin.isatty() and sys.stdout.isatty()
         while 1:
             try:
                 command = self.my_input('roundup> ')
+                # clear an input hook in case it was used to prefill
+                # buffer.
+                self.readline.set_pre_input_hook()
             except EOFError:
                 print(_('exit...'))
                 break
             if not command: continue  # noqa: E701
+            if command.startswith('!'):  # Pull numbered command from history
+                print_only = command.endswith(":p")
+                try:
+                    hist_num = int(command[1:]) \
+                        if not print_only else int(command[1:-2])
+                    command = self.readline.get_history_item(hist_num)
+                except ValueError:
+                    # pass the unknown command
+                    pass
+                else:
+                    if autosave_enabled and \
+                       hasattr(self.readline, "replace_history_item"):
+                        # history has the !23 input. Replace it if possible.
+                        # replace_history_item not supported by pyreadline3
+                        # so !23 will show up in history not the command.
+                        self.readline.replace_history_item(
+                            self.readline.get_current_history_length() - 1,
+                            command)
+
+                    if print_only:
+                        # fill the edit buffer with the command
+                        # the user selected.
+
+                        # from https://stackoverflow.com/questions/8505163/is-it-possible-to-prefill-a-input-in-python-3s-command-line-interface
+                        # This triggers:
+                        #   B023 Function definition does not bind loop variable
+                        #   `command`
+                        # in ruff. command will be the value of the command
+                        # variable at the time the function is run.
+                        # Not the value at define time. This is ok since
+                        # hook is run before command is changed by the
+                        # return from (readline) input.
+                        def hook():
+                            self.readline.insert_text(command)  # noqa: B023
+                            self.readline.redisplay()
+                        self.readline.set_pre_input_hook(hook)
+                        # we clear the hook after the next line is read.
+                        continue
+
+            if not autosave_enabled:
+                # needed to make testing work and also capture
+                # commands received on stdin from file/other command
+                # output. Disable saving with pragma on command line:
+                #    -P history_features=2.
+                self.readline.add_history(command)
+
             try:
                 args = token_r.token_split(command)
             except ValueError:
@@ -2426,8 +2598,9 @@
                 self.db.commit()
 
         # looks like histfile is saved with mode 600
-        if readline and self.history_features('save_history'):
-                readline.write_history_file(histfile)
+        if self.readline and self.history_features('save_history'):
+                self.readline.write_history_file(histfile)
+
         return 0
 
     def main(self):  # noqa: PLR0912, PLR0911
--- a/test/test_admin.py	Thu Aug 28 12:39:38 2025 -0400
+++ b/test/test_admin.py	Sun Aug 31 16:54:17 2025 -0400
@@ -5,8 +5,17 @@
 #
 
 from __future__ import print_function
+import difflib
+import errno
 import fileinput
-import unittest, os, shutil, errno, sys, difflib, re
+import io
+import os
+import platform
+import pytest
+import re
+import shutil
+import sys
+import unittest
 
 from roundup.admin import AdminTool
 
@@ -82,6 +91,10 @@
     def setUp(self):
         self.dirname = '_test_admin'
 
+    @pytest.fixture(autouse=True)
+    def inject_fixtures(self, monkeypatch):
+        self._monkeypatch = monkeypatch
+
     def tearDown(self):
         try:
             shutil.rmtree(self.dirname)
@@ -148,7 +161,9 @@
         print(ret)
         self.assertTrue(ret == 0)
         expected = 'ready for input.\nType "help" for help.'
-        self.assertEqual(expected, out[-1*len(expected):])
+        # back up by 30 to make sure 'ready for input' in slice.
+        self.assertIn(expected,
+                      "\n".join(out.split('\n')[-3:-1]))
 
         inputs = iter(["list user", "q"])
 
@@ -161,8 +176,10 @@
         
         print(ret)
         self.assertTrue(ret == 0)
-        expected = 'help.\n   1: admin\n   2: anonymous'
-        self.assertEqual(expected, out[-1*len(expected):])
+        expected = '   1: admin\n   2: anonymous'
+
+        self.assertEqual(expected,
+                         "\n".join(out.split('\n')[-2:]))
 
 
         AdminTool.my_input = orig_input
@@ -1104,7 +1121,7 @@
         
         print(ret)
         self.assertTrue(ret == 0)
-        self.assertEqual('Reopening tracker', out[2])
+        self.assertEqual('Reopening tracker', out[3])
         expected = '   _reopen_tracker=True'
         self.assertIn(expected, out)
 
@@ -1133,7 +1150,7 @@
                 ret = self.admin.main()
 
             out = out.getvalue().strip().split('\n')
-        
+
             print(ret)
             self.assertTrue(ret == 0)
             expected = '   verbose=True'
@@ -1155,7 +1172,7 @@
                 ret = self.admin.main()
 
             out = out.getvalue().strip().split('\n')
-        
+
             print(ret)
             self.assertTrue(ret == 0)
             expected = '   verbose=False'
@@ -1810,6 +1827,325 @@
         self.assertEqual(out, expected)
         self.assertEqual(len(err), 0)
 
+    def testReadline(self):
+        ''' Note the tests will fail if you run this under pdb.
+            the context managers capture the pdb prompts and this screws
+            up the stdout strings with (pdb) prefixed to the line.
+        '''
+
+        '''history didn't work when testing. The commands being
+           executed aren't being sent into the history
+           buffer. Failed under both windows and linux.
+
+           Explicitly using: readline.set_auto_history(True) in
+           roundup-admin setup had no effect.
+
+           Looks like monkeypatching stdin is the issue since:
+        
+              printf... | roundup-admin | tee
+
+           doesn't work either when printf uses
+
+              "readline vi\nreadline emacs\nreadline history\nquit\n"
+
+           Added explicit readline.add_history() if stdin or
+           stdout are not a tty to admin.py:interactive().
+
+           Still no way to drive editing with control/escape
+           chars to verify editing mode, check keybindings. Need
+           to trick Admintool to believe it's running on a
+           tty/pty/con in linux/windows to remove my hack.
+        '''
+
+        # Put the init file in the tracker test directory so
+        # we don't clobber user's actual init file.
+        original_home = None
+        if 'HOME' in os.environ:
+            original_home = os.environ['HOME']
+            os.environ['HOME'] = self.dirname
+
+        # same but for windows.
+        original_userprofile = None
+        if 'USERPROFILE' in os.environ:
+            # windows
+            original_userprofile = os.environ['USERPROFILE']
+            os.environ['USERPROFILE'] = self.dirname
+
+        inputs = ["readline vi", "readline emacs", "readline reload", "quit"]
+        
+        self._monkeypatch.setattr(
+            'sys.stdin',
+            io.StringIO("\n".join(inputs)))
+
+        self.install_init()
+        self.admin=AdminTool()
+
+        # disable loading and saving history
+        self.admin.settings['history_features'] = 3
+
+        # verify correct init file is being
+        self.assertIn(os.path.join(os.path.expanduser("~"),
+                                   ".roundup_admin_rlrc"),
+                      self.admin.get_readline_init_file())
+
+        # No exception is raised for missing file
+        # under pyreadline3. Detect pyreadline3 looking for:
+        #   readline.Readline
+        pyreadline = hasattr(self.admin.readline, "Readline")
+
+        sys.argv=['main', '-i', self.dirname]
+
+        with captured_output() as (out, err):
+            ret = self.admin.main()
+            out = out.getvalue().strip().split('\n')
+            
+        print(ret)
+        self.assertTrue(ret == 0)
+
+        expected = 'roundup> Enabled vi mode.'
+        self.assertIn(expected, out)
+
+        expected = 'roundup> Enabled emacs mode.'
+        self.assertIn(expected, out)
+
+        if not pyreadline:
+            expected = ('roundup> Init file %s '
+                        'not found.' % self.admin.get_readline_init_file())
+            self.assertIn(expected, out)
+
+        # --- test 2
+        
+        inputs = ["readline reload", "q"]
+        
+        self._monkeypatch.setattr(
+            'sys.stdin',
+            io.StringIO("\n".join(inputs)))
+
+        self.install_init()
+        self.admin=AdminTool()
+
+        with open(self.admin.get_readline_init_file(),
+                  "w") as config_file:
+            # there is no config line that works for all
+            # pyreadline3 (windows), readline(*nix), or editline
+            # (mac). So write empty file.
+            config_file.write("")
+
+        # disable loading and saving history
+        self.admin.settings['history_features'] = 3
+        sys.argv=['main', '-i', self.dirname]
+        
+        with captured_output() as (out, err):
+            ret = self.admin.main()
+            out = out.getvalue().strip().split('\n')
+            
+        print(ret)
+        self.assertTrue(ret == 0)
+
+        expected = ('roundup> File %s reloaded.' %
+                    self.admin.get_readline_init_file())
+        
+        self.assertIn(expected, out)
+
+        # === cleanup
+        if original_home:
+            os.environ['HOME'] = original_home
+        if original_userprofile:
+            os.environ['USERPROFILE'] = original_userprofile
+
+    def test_admin_history_save_load(self):
+        # To prevent overwriting/reading user's actual history,
+        # change HOME enviroment var.
+        original_home = None
+        if 'HOME' in os.environ:
+            original_home = os.environ['HOME']
+            os.environ['HOME'] = self.dirname
+        os.environ['HOME'] = self.dirname
+
+        # same idea but windows
+        original_userprofile = None
+        if 'USERPROFILE' in os.environ:
+            # windows
+            original_userprofile = os.environ['USERPROFILE']
+            os.environ['USERPROFILE'] = self.dirname
+
+        # -- history test
+        inputs = ["readline history", "q"]
+        
+        self._monkeypatch.setattr(
+            'sys.stdin',
+            io.StringIO("\n".join(inputs)))
+
+        self.install_init()
+        self.admin=AdminTool()
+
+        # use defaults load/save history
+        self.admin.settings['history_features'] = 0
+        
+        sys.argv=['main', '-i', self.dirname]
+        
+        with captured_output() as (out, err):
+            ret = self.admin.main()
+        out = out.getvalue().strip().split('\n')
+        
+        print(ret)
+        self.assertTrue(ret == 0)
+
+        expected = 'roundup> history size 1'
+        self.assertIn(expected, out)
+
+        expected = '  1 readline history'
+        self.assertIn(expected, out)
+
+        # -- history test 3 reruns readline vi
+        inputs = ["readline vi", "readline history", "!3",
+                  "readline history", "!23s", "q"]
+        
+        self._monkeypatch.setattr(
+            'sys.stdin',
+            io.StringIO("\n".join(inputs)))
+
+        # preserve directory self.install_init()
+        self.admin=AdminTool()
+
+        # default use all features
+        #self.admin.settings['history_features'] = 3
+        sys.argv=['main', '-i', self.dirname]
+        
+        with captured_output() as (out, err):
+            ret = self.admin.main()
+        out = out.getvalue().strip().split('\n')
+        
+        print(ret)
+        self.assertTrue(ret == 0)
+
+        # 4 includes 2 commands in saved history
+        expected = 'roundup> history size 4'
+        self.assertIn(expected, out)
+
+        expected = '  4 readline history'
+        self.assertIn(expected, out)
+
+        # Shouldn't work on windows.
+        if platform.system() != "Windows":
+            expected = '  5 readline vi'
+            self.assertIn(expected, out)
+        else:
+            # PYREADLINE UNDER WINDOWS
+            # py3readline on windows can't replace
+            # command strings in history when connected
+            # to a console. (Console triggers autosave and
+            # I have to turn !3 into it's substituted value.)
+            # but in testing autosave is disabled so
+            # I don't get the !number but the actual command
+            # It should have 
+            #
+            #             expected = '  5 !3'
+            #
+            # but it is the same as the unix case.
+            expected = '  5 readline vi'
+            self.assertIn(expected, out)
+
+        expected = ('roundup> Unknown command "!23s" ("help commands" '
+                    'for a list)')
+        self.assertIn(expected, out)
+        
+        print(out)
+        # can't test !#:p mode as readline editing doesn't work
+        # if not in a tty.
+        
+        # === cleanup
+        if original_home:
+            os.environ['HOME'] = original_home
+        if original_userprofile:
+            os.environ['USERPROFILE'] = original_userprofile
+        
+    def test_admin_readline_history(self):
+        original_home = os.environ['HOME']
+        # To prevent overwriting/reading user's actual history,
+        # change HOME enviroment var.
+        os.environ['HOME'] = self.dirname
+
+        original_userprofile = None
+        if 'USERPROFILE' in os.environ:
+            # windows
+            original_userprofile = os.environ['USERPROFILE']
+            os.environ['USERPROFILE'] = self.dirname
+
+        # -- history test
+        inputs = ["readline history", "q"]
+        
+        self._monkeypatch.setattr(
+            'sys.stdin',
+            io.StringIO("\n".join(inputs)))
+
+        self.install_init()
+        self.admin=AdminTool()
+
+        # disable loading, but save history
+        self.admin.settings['history_features'] = 3
+        sys.argv=['main', '-i', self.dirname]
+        
+        with captured_output() as (out, err):
+            ret = self.admin.main()
+        out = out.getvalue().strip().split('\n')
+        
+        print(ret)
+        self.assertTrue(ret == 0)
+
+        expected = 'roundup> history size 1'
+        self.assertIn(expected, out)
+
+        expected = '  1 readline history'
+        self.assertIn(expected, out)
+
+        # -- history test
+        inputs = ["readline vi", "readline history", "!1", "!2", "q"]
+        
+        self._monkeypatch.setattr(
+            'sys.stdin',
+            io.StringIO("\n".join(inputs)))
+
+        self.install_init()
+        self.admin=AdminTool()
+
+        # disable loading, but save history
+        self.admin.settings['history_features'] = 3
+        sys.argv=['main', '-i', self.dirname]
+        
+        with captured_output() as (out, err):
+            ret = self.admin.main()
+        out = out.getvalue().strip().split('\n')
+        
+        print(ret)
+        self.assertTrue(ret == 0)
+
+        expected = 'roundup> history size 2'
+        self.assertIn(expected, out)
+
+        expected = '  2 readline history'
+        self.assertIn(expected, out)
+
+        # doesn't work on windows.
+        if platform.system() != "Windows":
+            expected = '  4 readline history'
+            self.assertIn(expected, out)
+        else:
+            # See 
+            # PYREADLINE UNDER WINDOWS
+            # elsewhere in this file for why I am not checking for
+            #  expected = '  4 !2'
+            expected = '  4 readline history'
+            self.assertIn(expected, out)
+
+        # can't test !#:p mode as readline editing doesn't work
+        # if not in a tty.
+        
+        # === cleanup
+        os.environ['HOME'] = original_home
+        if original_userprofile:
+            os.environ['USERPROFILE'] = original_userprofile
+
     def testSpecification(self):
         ''' Note the tests will fail if you run this under pdb.
             the context managers capture the pdb prompts and this screws

Roundup Issue Tracker: http://roundup-tracker.org/