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