Skip to content

Commit c53b5cd

Browse files
committed
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.
1 parent 2bdcc6b commit c53b5cd

File tree

4 files changed

+586
-23
lines changed

4 files changed

+586
-23
lines changed

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ Features:
3636
style configs will still be supported. (John Rouillard)
3737
- add 'q' as alias for quit in roundup-admin interactive mode. (John
3838
Rouillard)
39+
- add readline command to roundup-admin to list history, control input
40+
mode etc. Also support bang (!) commands to rerun commands in history
41+
or put them in the input buffer for editing. (John Rouillard)
3942

4043
2025-07-13 2.5.0
4144

doc/admin_guide.txt

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2202,6 +2202,57 @@ https://pythonhosted.org/pyreadline/usage.html#configuration-file.
22022202
History is saved to the file ``.roundup_admin_history`` in your home
22032203
directory (for windows usually ``\Users\<username>``.
22042204

2205+
In Roundup 2.6.0 and newer, you can use the ``readline`` command to
2206+
make changes on the fly.
2207+
2208+
* ``readline vi`` - change input mode to use vi key binding when
2209+
editing. It starts in entry mode.
2210+
* ``readline emacs`` - change input mode to emacs key bindings when
2211+
editing. This is also the default.
2212+
* ``readline reload`` - reloads the ``~/.roundup_admin_rlrc`` file so
2213+
you can test and use changes.
2214+
* ``readline history`` - dumps the history buffer and numbers all
2215+
commands.
2216+
* ``readline .inputrc_command_line`` can be used to make on the fly
2217+
key and key sequence bindings to readline commands. It can also be
2218+
used to change the internal readline settings using a set
2219+
command. For example::
2220+
2221+
readline set bell-style none
2222+
2223+
will turn off a ``visible`` or ``audible`` bell. Single character
2224+
keybindings::
2225+
2226+
readline Control-o: dump-variables
2227+
2228+
to list all the variables that can be set are supported. As are
2229+
multi-character bindings::
2230+
2231+
readline "\C-o1": "commit"
2232+
2233+
will put "commit" on the input line when you type Control-o followed
2234+
by 1. See the `readline manual for details
2235+
<https://tiswww.cwru.edu/php/chet/readline/rluserman.html#Readline-Init-File-Syntax-1>`_
2236+
on the command lines that can be used.
2237+
2238+
Also a limited form of ``!`` (bang) history reference was added. The
2239+
reference must be at the start of the line. Typing ``!23`` will rerun
2240+
command number 23 from your history.
2241+
2242+
Typing ``!23:p`` will load command 23 into the buffer so you can edit
2243+
and submit it. Using the bang feature will append the command to the
2244+
end of the history list.
2245+
2246+
Pyreadline3 users can use ``readline history`` and the
2247+
bang commands (including ``:p``). Single character bindings can be
2248+
done. For example::
2249+
2250+
readline Control-w: history-search-backward
2251+
2252+
The commands that are available are limited compared to Unix's
2253+
readline or libedit. Setting variables or entry mode (emacs,
2254+
vi) switching do not work in testing.
2255+
22052256
Using with the shell
22062257
--------------------
22072258

roundup/admin.py

Lines changed: 189 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@
3434

3535
import roundup.instance
3636
from roundup import __version__ as roundup_version
37-
from roundup import date, hyperdb, init, password, token_r
38-
from roundup.anypy import scandir_
37+
from roundup import date, hyperdb, init, password, support, token_r
38+
from roundup.anypy import scandir_ # noqa: F401 define os.scandir
3939
from roundup.anypy.my_input import my_input
4040
from roundup.anypy.strings import repr_export
4141
from roundup.configuration import (
@@ -49,7 +49,6 @@
4949
)
5050
from roundup.exceptions import UsageError
5151
from roundup.i18n import _, get_translation
52-
from roundup import support
5352

5453
try:
5554
from UserDict import UserDict
@@ -93,6 +92,13 @@ class AdminTool:
9392
Additional help may be supplied by help_*() methods.
9493
"""
9594

95+
# import here to make AdminTool.readline accessible or
96+
# mockable from tests.
97+
try:
98+
import readline # noqa: I001, PLC0415
99+
except ImportError:
100+
readline = None
101+
96102
# Make my_input a property to allow overriding in testing.
97103
# my_input is imported in other places, so just set it from
98104
# the imported value rather than moving def here.
@@ -1815,6 +1821,111 @@ def do_pragma(self, args):
18151821
type(self.settings[setting]).__name__)
18161822
self.settings[setting] = value
18171823

1824+
def do_readline(self, args):
1825+
''"""Usage: readline initrc_line | 'emacs' | 'history' | 'reload' | 'vi'
1826+
1827+
Using 'reload' will reload the file ~/.roundup_admin_rlrc.
1828+
'history' will show (and number) all commands in the history.
1829+
1830+
You can change input mode using the 'emacs' or 'vi' parameters.
1831+
The default is emacs. This is the same as using::
1832+
1833+
readline set editing-mode emacs
1834+
1835+
or::
1836+
1837+
readline set editing-mode vi
1838+
1839+
Any command that can be placed in a readline .inputrc file can
1840+
be executed using the readline command. You can assign
1841+
dump-variables to control O using::
1842+
1843+
readline Control-o: dump-variables
1844+
1845+
Assigning multi-key values also works.
1846+
1847+
pyreadline3 support on windows:
1848+
1849+
Mode switching doesn't work, emacs only.
1850+
1851+
Binding single key commands works with::
1852+
1853+
readline Control-w: history-search-backward
1854+
1855+
Multiple key sequences don't work.
1856+
1857+
Setting values may work. Difficult to tell because the library
1858+
has no way to view the live settings.
1859+
1860+
"""
1861+
1862+
# TODO: allow history 20 # most recent 20 commands
1863+
# history 100-200 # show commands 100-200
1864+
1865+
if not self.readline:
1866+
print(_("Readline support is not available."))
1867+
return
1868+
# The if test allows pyreadline3 settings like:
1869+
# bind_exit_key("Control-z") get through to
1870+
# parse_and_bind(). It is not obvious that this form of
1871+
# command is supported. Pyreadline3 is supposed to parse
1872+
# readline style commands, so we use those for emacs/vi.
1873+
# Trying set-mode(...) as in the pyreadline3 init file
1874+
# didn't work in testing.
1875+
1876+
if len(args) == 1 and args[0].find('(') == -1:
1877+
if args[0] == "vi":
1878+
self.readline.parse_and_bind("set editing-mode vi")
1879+
print(_("Enabled vi mode."))
1880+
elif args[0] == "emacs":
1881+
self.readline.parse_and_bind("set editing-mode emacs")
1882+
print(_("Enabled emacs mode."))
1883+
elif args[0] == "history":
1884+
print("history size",
1885+
self.readline.get_current_history_length())
1886+
print('\n'.join([
1887+
str("%3d " % (i + 1) +
1888+
self.readline.get_history_item(i + 1))
1889+
for i in range(
1890+
self.readline.get_current_history_length())
1891+
]))
1892+
elif args[0] == "reload":
1893+
try:
1894+
# readline is a singleton. In testing previous
1895+
# tests using read_init_file are loading from ~
1896+
# not the test directory because it doesn't
1897+
# matter. But for reload we want to test with the
1898+
# init file under the test directory. Calling
1899+
# read_init_file() calls with the ~/.. init
1900+
# location and I can't seem to reset it
1901+
# or the readline state.
1902+
# So call with explicit file here.
1903+
self.readline.read_init_file(
1904+
self.get_readline_init_file())
1905+
except FileNotFoundError as e:
1906+
# If user invoked reload explicitly, report
1907+
# if file not found.
1908+
#
1909+
# DOES NOT WORK with pyreadline3. Exception
1910+
# is not raised if file is missing.
1911+
#
1912+
# Also e.filename is None under cygwin. A
1913+
# simple test case does set e.filename
1914+
# correctly?? sigh. So I just call
1915+
# get_readline_init_file again to get
1916+
# filename.
1917+
fn = e.filename or self.get_readline_init_file()
1918+
print(_("Init file %s not found.") % fn)
1919+
else:
1920+
print(_("File %s reloaded.") %
1921+
self.get_readline_init_file())
1922+
else:
1923+
print(_("Unknown readline parameter %s") % args[0])
1924+
return
1925+
1926+
self.readline.parse_and_bind(" ".join(args))
1927+
return
1928+
18181929
designator_re = re.compile('([A-Za-z]+)([0-9]+)$')
18191930
designator_rng = re.compile('([A-Za-z]+):([0-9]+)-([0-9]+)$')
18201931

@@ -2365,29 +2476,34 @@ def history_features(self, feature):
23652476
# setting the bit disables the feature, so use not.
23662477
return not self.settings['history_features'] & features[feature]
23672478

2479+
def get_readline_init_file(self):
2480+
return os.path.join(os.path.expanduser("~"),
2481+
".roundup_admin_rlrc")
2482+
23682483
def interactive(self):
23692484
"""Run in an interactive mode
23702485
"""
23712486
print(_('Roundup %s ready for input.\nType "help" for help.')
23722487
% roundup_version)
23732488

2374-
initfile = os.path.join(os.path.expanduser("~"),
2375-
".roundup_admin_rlrc")
2489+
initfile = self.get_readline_init_file()
23762490
histfile = os.path.join(os.path.expanduser("~"),
23772491
".roundup_admin_history")
23782492

2379-
try:
2380-
import readline
2493+
if self.readline:
2494+
# clear any history that might be left over from caller
2495+
# when reusing AdminTool from tests or program.
2496+
self.readline.clear_history()
23812497
try:
23822498
if self.history_features('load_rc'):
2383-
readline.read_init_file(initfile)
2384-
except IOError: # FileNotFoundError under python3
2499+
self.readline.read_init_file(initfile)
2500+
except FileNotFoundError:
23852501
# file is optional
23862502
pass
23872503

23882504
try:
23892505
if self.history_features('load_history'):
2390-
readline.read_history_file(histfile)
2506+
self.readline.read_history_file(histfile)
23912507
except IOError: # FileNotFoundError under python3
23922508
# no history file yet
23932509
pass
@@ -2397,19 +2513,75 @@ def interactive(self):
23972513
# Pragma history_length allows setting on a per
23982514
# invocation basis at startup
23992515
if self.settings['history_length'] != -1:
2400-
readline.set_history_length(
2516+
self.readline.set_history_length(
24012517
self.settings['history_length'])
2402-
except ImportError:
2403-
readline = None
2404-
print(_('Note: command history and editing not available'))
24052518

2519+
if hasattr(self.readline, 'backend'):
2520+
# FIXME after min 3.13 version; no backend prints pyreadline3
2521+
print(_("Readline enabled using %s.") % self.readline.backend)
2522+
else:
2523+
print(_("Readline enabled using unknown library."))
2524+
2525+
else:
2526+
print(_('Command history and line editing not available'))
2527+
2528+
autosave_enabled = sys.stdin.isatty() and sys.stdout.isatty()
24062529
while 1:
24072530
try:
24082531
command = self.my_input('roundup> ')
2532+
# clear an input hook in case it was used to prefill
2533+
# buffer.
2534+
self.readline.set_pre_input_hook()
24092535
except EOFError:
24102536
print(_('exit...'))
24112537
break
24122538
if not command: continue # noqa: E701
2539+
if command.startswith('!'): # Pull numbered command from history
2540+
print_only = command.endswith(":p")
2541+
try:
2542+
hist_num = int(command[1:]) \
2543+
if not print_only else int(command[1:-2])
2544+
command = self.readline.get_history_item(hist_num)
2545+
except ValueError:
2546+
# pass the unknown command
2547+
pass
2548+
else:
2549+
if autosave_enabled and \
2550+
hasattr(self.readline, "replace_history_item"):
2551+
# history has the !23 input. Replace it if possible.
2552+
# replace_history_item not supported by pyreadline3
2553+
# so !23 will show up in history not the command.
2554+
self.readline.replace_history_item(
2555+
self.readline.get_current_history_length() - 1,
2556+
command)
2557+
2558+
if print_only:
2559+
# fill the edit buffer with the command
2560+
# the user selected.
2561+
2562+
# from https://stackoverflow.com/questions/8505163/is-it-possible-to-prefill-a-input-in-python-3s-command-line-interface
2563+
# This triggers:
2564+
# B023 Function definition does not bind loop variable
2565+
# `command`
2566+
# in ruff. command will be the value of the command
2567+
# variable at the time the function is run.
2568+
# Not the value at define time. This is ok since
2569+
# hook is run before command is changed by the
2570+
# return from (readline) input.
2571+
def hook():
2572+
self.readline.insert_text(command) # noqa: B023
2573+
self.readline.redisplay()
2574+
self.readline.set_pre_input_hook(hook)
2575+
# we clear the hook after the next line is read.
2576+
continue
2577+
2578+
if not autosave_enabled:
2579+
# needed to make testing work and also capture
2580+
# commands received on stdin from file/other command
2581+
# output. Disable saving with pragma on command line:
2582+
# -P history_features=2.
2583+
self.readline.add_history(command)
2584+
24132585
try:
24142586
args = token_r.token_split(command)
24152587
except ValueError:
@@ -2426,8 +2598,9 @@ def interactive(self):
24262598
self.db.commit()
24272599

24282600
# looks like histfile is saved with mode 600
2429-
if readline and self.history_features('save_history'):
2430-
readline.write_history_file(histfile)
2601+
if self.readline and self.history_features('save_history'):
2602+
self.readline.write_history_file(histfile)
2603+
24312604
return 0
24322605

24332606
def main(self): # noqa: PLR0912, PLR0911

0 commit comments

Comments
 (0)