Mercurial > p > roundup > code
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
