comparison roundup/admin.py @ 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 1a93dc58f975
children 254f70dfc585
comparison
equal deleted inserted replaced
8438:98e17dd0197f 8439:3bdae15252c6
32 import shutil 32 import shutil
33 import sys 33 import sys
34 34
35 import roundup.instance 35 import roundup.instance
36 from roundup import __version__ as roundup_version 36 from roundup import __version__ as roundup_version
37 from roundup import date, hyperdb, init, password, token_r 37 from roundup import date, hyperdb, init, password, support, token_r
38 from roundup.anypy import scandir_ 38 from roundup.anypy import scandir_ # noqa: F401 define os.scandir
39 from roundup.anypy.my_input import my_input 39 from roundup.anypy.my_input import my_input
40 from roundup.anypy.strings import repr_export 40 from roundup.anypy.strings import repr_export
41 from roundup.configuration import ( 41 from roundup.configuration import (
42 CoreConfig, 42 CoreConfig,
43 NoConfigError, 43 NoConfigError,
47 ParsingOptionError, 47 ParsingOptionError,
48 UserConfig, 48 UserConfig,
49 ) 49 )
50 from roundup.exceptions import UsageError 50 from roundup.exceptions import UsageError
51 from roundup.i18n import _, get_translation 51 from roundup.i18n import _, get_translation
52 from roundup import support
53 52
54 try: 53 try:
55 from UserDict import UserDict 54 from UserDict import UserDict
56 except ImportError: 55 except ImportError:
57 from collections import UserDict 56 from collections import UserDict
90 Actions are defined by do_*() methods, with help for the action 89 Actions are defined by do_*() methods, with help for the action
91 given in the method docstring. 90 given in the method docstring.
92 91
93 Additional help may be supplied by help_*() methods. 92 Additional help may be supplied by help_*() methods.
94 """ 93 """
94
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
95 101
96 # Make my_input a property to allow overriding in testing. 102 # Make my_input a property to allow overriding in testing.
97 # my_input is imported in other places, so just set it from 103 # my_input is imported in other places, so just set it from
98 # the imported value rather than moving def here. 104 # the imported value rather than moving def here.
99 my_input = my_input 105 my_input = my_input
1813 raise UsageError(_('Internal error: pragma can not handle ' 1819 raise UsageError(_('Internal error: pragma can not handle '
1814 'values of type: %s') % 1820 'values of type: %s') %
1815 type(self.settings[setting]).__name__) 1821 type(self.settings[setting]).__name__)
1816 self.settings[setting] = value 1822 self.settings[setting] = value
1817 1823
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
1818 designator_re = re.compile('([A-Za-z]+)([0-9]+)$') 1929 designator_re = re.compile('([A-Za-z]+)([0-9]+)$')
1819 designator_rng = re.compile('([A-Za-z]+):([0-9]+)-([0-9]+)$') 1930 designator_rng = re.compile('([A-Za-z]+):([0-9]+)-([0-9]+)$')
1820 1931
1821 def do_reindex(self, args, desre=designator_re, desrng=designator_rng): 1932 def do_reindex(self, args, desre=designator_re, desrng=designator_rng):
1822 ''"""Usage: reindex [classname|classname:#-#|designator]* 1933 ''"""Usage: reindex [classname|classname:#-#|designator]*
2363 'load_rc': 4} 2474 'load_rc': 4}
2364 2475
2365 # setting the bit disables the feature, so use not. 2476 # setting the bit disables the feature, so use not.
2366 return not self.settings['history_features'] & features[feature] 2477 return not self.settings['history_features'] & features[feature]
2367 2478
2479 def get_readline_init_file(self):
2480 return os.path.join(os.path.expanduser("~"),
2481 ".roundup_admin_rlrc")
2482
2368 def interactive(self): 2483 def interactive(self):
2369 """Run in an interactive mode 2484 """Run in an interactive mode
2370 """ 2485 """
2371 print(_('Roundup %s ready for input.\nType "help" for help.') 2486 print(_('Roundup %s ready for input.\nType "help" for help.')
2372 % roundup_version) 2487 % roundup_version)
2373 2488
2374 initfile = os.path.join(os.path.expanduser("~"), 2489 initfile = self.get_readline_init_file()
2375 ".roundup_admin_rlrc")
2376 histfile = os.path.join(os.path.expanduser("~"), 2490 histfile = os.path.join(os.path.expanduser("~"),
2377 ".roundup_admin_history") 2491 ".roundup_admin_history")
2378 2492
2379 try: 2493 if self.readline:
2380 import 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()
2381 try: 2497 try:
2382 if self.history_features('load_rc'): 2498 if self.history_features('load_rc'):
2383 readline.read_init_file(initfile) 2499 self.readline.read_init_file(initfile)
2384 except IOError: # FileNotFoundError under python3 2500 except FileNotFoundError:
2385 # file is optional 2501 # file is optional
2386 pass 2502 pass
2387 2503
2388 try: 2504 try:
2389 if self.history_features('load_history'): 2505 if self.history_features('load_history'):
2390 readline.read_history_file(histfile) 2506 self.readline.read_history_file(histfile)
2391 except IOError: # FileNotFoundError under python3 2507 except IOError: # FileNotFoundError under python3
2392 # no history file yet 2508 # no history file yet
2393 pass 2509 pass
2394 2510
2395 # Default history length is unlimited. 2511 # Default history length is unlimited.
2396 # Set persistently in readline init file 2512 # Set persistently in readline init file
2397 # Pragma history_length allows setting on a per 2513 # Pragma history_length allows setting on a per
2398 # invocation basis at startup 2514 # invocation basis at startup
2399 if self.settings['history_length'] != -1: 2515 if self.settings['history_length'] != -1:
2400 readline.set_history_length( 2516 self.readline.set_history_length(
2401 self.settings['history_length']) 2517 self.settings['history_length'])
2402 except ImportError: 2518
2403 readline = None 2519 if hasattr(self.readline, 'backend'):
2404 print(_('Note: command history and editing not available')) 2520 # FIXME after min 3.13 version; no backend prints pyreadline3
2405 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()
2406 while 1: 2529 while 1:
2407 try: 2530 try:
2408 command = self.my_input('roundup> ') 2531 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()
2409 except EOFError: 2535 except EOFError:
2410 print(_('exit...')) 2536 print(_('exit...'))
2411 break 2537 break
2412 if not command: continue # noqa: E701 2538 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
2413 try: 2585 try:
2414 args = token_r.token_split(command) 2586 args = token_r.token_split(command)
2415 except ValueError: 2587 except ValueError:
2416 continue # Ignore invalid quoted token 2588 continue # Ignore invalid quoted token
2417 if not args: continue # noqa: E701 2589 if not args: continue # noqa: E701
2424 commit = self.my_input(_('There are unsaved changes. Commit them (y/N)? ')) 2596 commit = self.my_input(_('There are unsaved changes. Commit them (y/N)? '))
2425 if commit and commit[0].lower() == 'y': 2597 if commit and commit[0].lower() == 'y':
2426 self.db.commit() 2598 self.db.commit()
2427 2599
2428 # looks like histfile is saved with mode 600 2600 # looks like histfile is saved with mode 600
2429 if readline and self.history_features('save_history'): 2601 if self.readline and self.history_features('save_history'):
2430 readline.write_history_file(histfile) 2602 self.readline.write_history_file(histfile)
2603
2431 return 0 2604 return 0
2432 2605
2433 def main(self): # noqa: PLR0912, PLR0911 2606 def main(self): # noqa: PLR0912, PLR0911
2434 try: 2607 try:
2435 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdP:sS:vV') 2608 opts, args = getopt.getopt(sys.argv[1:], 'i:u:hcdP:sS:vV')

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