Skip to content

Commit 6b6a36c

Browse files
authored
Merge pull request #416 from python-cmd2/pyshell_readline
Pyshell readline
2 parents cad21a6 + 4c67d33 commit 6b6a36c

File tree

3 files changed

+166
-19
lines changed

3 files changed

+166
-19
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Bug Fixes
33
* If self.default_to_shell is true, then redirection and piping are now properly passed to the shell. Previously it was truncated.
44
* Submenus now call all hooks, it used to just call precmd and postcmd.
5+
* Fixed ``AttributeError`` on Windows when running a ``select`` command cause by **pyreadline** not implementing ``remove_history_item``
56
* Enhancements
67
* Automatic completion of ``argparse`` arguments via ``cmd2.argparse_completer.AutoCompleter``
78
* See the [tab_autocompletion.py](https://github.com/python-cmd2/cmd2/blob/master/examples/tab_autocompletion.py) example for a demonstration of how to use this feature
@@ -16,6 +17,9 @@
1617
* ``identchars`` is now ignored. The standardlibrary cmd uses those characters to split the first "word" of the input, but cmd2 hasn't used those for a while, and the new parsing logic parses on whitespace, which has the added benefit of full unicode support, unlike cmd or prior versions of cmd2.
1718
* ``set_posix_shlex`` function and ``POSIX_SHLEX`` variable have been removed. Parsing behavior is now always the more forgiving ``posix=false``.
1819
* ``set_strip_quotes`` function and ``STRIP_QUOTES_FOR_NON_POSIX`` have been removed. Quotes are stripped from arguments when presented as a list (a la ``sys.argv``), and present when arguments are presented as a string (like the string passed to do_*).
20+
* Enhanced the ``py`` console in the following ways
21+
* Added tab completion of Python identifiers instead of **cmd2** commands
22+
* Separated the ``py`` console history from the **cmd2** history
1923
* Changes
2024
* ``strip_ansi()`` and ``strip_quotes()`` functions have moved to new utils module
2125
* Several constants moved to new constants module

cmd2/cmd2.py

100755100644
Lines changed: 130 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@
5858
else:
5959
from .rl_utils import rl_force_redisplay, readline
6060

61+
# Used by rlcompleter in Python console loaded by py command
62+
orig_rl_delims = readline.get_completer_delims()
63+
6164
if rl_type == RlType.PYREADLINE:
6265

6366
# Save the original pyreadline display completion function since we need to override it and restore it
@@ -73,6 +76,9 @@
7376
import ctypes
7477
from .rl_utils import readline_lib
7578

79+
rl_basic_quote_characters = ctypes.c_char_p.in_dll(readline_lib, "rl_basic_quote_characters")
80+
orig_rl_basic_quotes = ctypes.cast(rl_basic_quote_characters, ctypes.c_void_p).value
81+
7682
from .argparse_completer import AutoCompleter, ACArgumentParser
7783

7884
# Newer versions of pyperclip are released as a single file, but older versions had a more complicated structure
@@ -416,6 +422,7 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, persistent_histor
416422
self.initial_stdout = sys.stdout
417423
self.history = History()
418424
self.pystate = {}
425+
self.py_history = []
419426
self.pyscript_name = 'app'
420427
self.keywords = self.reserved_words + [fname[3:] for fname in dir(self) if fname.startswith('do_')]
421428
self.statement_parser = StatementParser(
@@ -476,7 +483,7 @@ def __init__(self, completekey='tab', stdin=None, stdout=None, persistent_histor
476483

477484
############################################################################################################
478485
# The following variables are used by tab-completion functions. They are reset each time complete() is run
479-
# using set_completion_defaults() and it is up to completer functions to set them before returning results.
486+
# in reset_completion_defaults() and it is up to completer functions to set them before returning results.
480487
############################################################################################################
481488

482489
# If true and a single match is returned to complete(), then a space will be appended
@@ -643,7 +650,7 @@ def colorize(self, val, color):
643650

644651
# ----- Methods related to tab completion -----
645652

646-
def set_completion_defaults(self):
653+
def reset_completion_defaults(self):
647654
"""
648655
Resets tab completion settings
649656
Needs to be called each time readline runs tab completion
@@ -1285,7 +1292,7 @@ def complete(self, text, state):
12851292
import functools
12861293
if state == 0 and rl_type != RlType.NONE:
12871294
unclosed_quote = ''
1288-
self.set_completion_defaults()
1295+
self.reset_completion_defaults()
12891296

12901297
# lstrip the original line
12911298
orig_line = readline.get_line_buffer()
@@ -2026,12 +2033,10 @@ def _cmdloop(self):
20262033
# Set GNU readline's rl_basic_quote_characters to NULL so it won't automatically add a closing quote
20272034
# We don't need to worry about setting rl_completion_suppress_quote since we never declared
20282035
# rl_completer_quote_characters.
2029-
basic_quote_characters = ctypes.c_char_p.in_dll(readline_lib, "rl_basic_quote_characters")
2030-
old_basic_quote_characters = ctypes.cast(basic_quote_characters, ctypes.c_void_p).value
2031-
basic_quote_characters.value = None
2036+
old_basic_quotes = ctypes.cast(rl_basic_quote_characters, ctypes.c_void_p).value
2037+
rl_basic_quote_characters.value = None
20322038

20332039
old_completer = readline.get_completer()
2034-
old_delims = readline.get_completer_delims()
20352040
readline.set_completer(self.complete)
20362041

20372042
# Break words on whitespace and quotes when tab completing
@@ -2041,6 +2046,7 @@ def _cmdloop(self):
20412046
# If redirection is allowed, then break words on those characters too
20422047
completer_delims += ''.join(constants.REDIRECTION_CHARS)
20432048

2049+
old_delims = readline.get_completer_delims()
20442050
readline.set_completer_delims(completer_delims)
20452051

20462052
# Enable tab completion
@@ -2077,7 +2083,7 @@ def _cmdloop(self):
20772083

20782084
if rl_type == RlType.GNU:
20792085
readline.set_completion_display_matches_hook(None)
2080-
basic_quote_characters.value = old_basic_quote_characters
2086+
rl_basic_quote_characters.value = old_basic_quotes
20812087
elif rl_type == RlType.PYREADLINE:
20822088
readline.rl.mode._display_completions = orig_pyreadline_display
20832089

@@ -2507,7 +2513,30 @@ def complete_shell(self, text, line, begidx, endidx):
25072513
index_dict = {1: self.shell_cmd_complete}
25082514
return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete)
25092515

2510-
# noinspection PyBroadException
2516+
@staticmethod
2517+
def _reset_py_display() -> None:
2518+
"""
2519+
Resets the dynamic objects in the sys module that the py and ipy consoles fight over.
2520+
When a Python console starts it adopts certain display settings if they've already been set.
2521+
If an ipy console has previously been run, then py uses its settings and ends up looking
2522+
like an ipy console in terms of prompt and exception text. This method forces the Python
2523+
console to create its own display settings since they won't exist.
2524+
2525+
IPython does not have this problem since it always overwrites the display settings when it
2526+
is run. Therefore this method only needs to be called before creating a Python console.
2527+
"""
2528+
# Delete any prompts that have been set
2529+
attributes = ['ps1', 'ps2', 'ps3']
2530+
for cur_attr in attributes:
2531+
try:
2532+
del sys.__dict__[cur_attr]
2533+
except KeyError:
2534+
pass
2535+
2536+
# Reset functions
2537+
sys.displayhook = sys.__displayhook__
2538+
sys.excepthook = sys.__excepthook__
2539+
25112540
def do_py(self, arg):
25122541
"""
25132542
Invoke python command, shell, or script
@@ -2524,6 +2553,7 @@ def do_py(self, arg):
25242553
return
25252554
self._in_py = True
25262555

2556+
# noinspection PyBroadException
25272557
try:
25282558
arg = arg.strip()
25292559

@@ -2539,6 +2569,7 @@ def run(filename):
25392569
except IOError as e:
25402570
self.perror(e)
25412571

2572+
# noinspection PyUnusedLocal
25422573
def onecmd_plus_hooks(cmd_plus_args):
25432574
"""Run a cmd2.Cmd command from a Python script or the interactive Python console.
25442575
@@ -2561,6 +2592,8 @@ def onecmd_plus_hooks(cmd_plus_args):
25612592

25622593
if arg:
25632594
interp.runcode(arg)
2595+
2596+
# If there are no args, then we will open an interactive Python console
25642597
else:
25652598
# noinspection PyShadowingBuiltins
25662599
def quit():
@@ -2570,20 +2603,98 @@ def quit():
25702603
self.pystate['quit'] = quit
25712604
self.pystate['exit'] = quit
25722605

2573-
keepstate = None
2606+
# Set up readline for Python console
2607+
if rl_type != RlType.NONE:
2608+
# Save cmd2 history
2609+
saved_cmd2_history = []
2610+
for i in range(1, readline.get_current_history_length() + 1):
2611+
saved_cmd2_history.append(readline.get_history_item(i))
2612+
2613+
readline.clear_history()
2614+
2615+
# Restore py's history
2616+
for item in self.py_history:
2617+
readline.add_history(item)
2618+
2619+
if self.use_rawinput and self.completekey:
2620+
# Set up tab completion for the Python console
2621+
# rlcompleter relies on the default settings of the Python readline module
2622+
if rl_type == RlType.GNU:
2623+
old_basic_quotes = ctypes.cast(rl_basic_quote_characters, ctypes.c_void_p).value
2624+
rl_basic_quote_characters.value = orig_rl_basic_quotes
2625+
2626+
if 'gnureadline' in sys.modules:
2627+
# rlcompleter imports readline by name, so it won't use gnureadline
2628+
# Force rlcompleter to use gnureadline instead so it has our settings and history
2629+
saved_readline = None
2630+
if 'readline' in sys.modules:
2631+
saved_readline = sys.modules['readline']
2632+
2633+
sys.modules['readline'] = sys.modules['gnureadline']
2634+
2635+
old_delims = readline.get_completer_delims()
2636+
readline.set_completer_delims(orig_rl_delims)
2637+
2638+
# rlcompleter will not need cmd2's custom display function
2639+
# This will be restored by cmd2 the next time complete() is called
2640+
if rl_type == RlType.GNU:
2641+
readline.set_completion_display_matches_hook(None)
2642+
elif rl_type == RlType.PYREADLINE:
2643+
readline.rl.mode._display_completions = self._display_matches_pyreadline
2644+
2645+
# Save off the current completer and set a new one in the Python console
2646+
# Make sure it tab completes from its locals() dictionary
2647+
old_completer = readline.get_completer()
2648+
interp.runcode("from rlcompleter import Completer")
2649+
interp.runcode("import readline")
2650+
interp.runcode("readline.set_completer(Completer(locals()).complete)")
2651+
2652+
# Set up sys module for the Python console
2653+
self._reset_py_display()
2654+
keepstate = Statekeeper(sys, ('stdin', 'stdout'))
2655+
sys.stdout = self.stdout
2656+
sys.stdin = self.stdin
2657+
2658+
cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
2659+
docstr = self.do_py.__doc__.replace('pyscript_name', self.pyscript_name)
2660+
25742661
try:
2575-
cprt = 'Type "help", "copyright", "credits" or "license" for more information.'
2576-
keepstate = Statekeeper(sys, ('stdin', 'stdout'))
2577-
sys.stdout = self.stdout
2578-
sys.stdin = self.stdin
2579-
docstr = self.do_py.__doc__.replace('pyscript_name', self.pyscript_name)
2580-
interp.interact(banner="Python %s on %s\n%s\n(%s)\n%s" %
2581-
(sys.version, sys.platform, cprt, self.__class__.__name__,
2582-
docstr))
2662+
interp.interact(banner="Python {} on {}\n{}\n({})\n{}".
2663+
format(sys.version, sys.platform, cprt, self.__class__.__name__, docstr))
25832664
except EmbeddedConsoleExit:
25842665
pass
2585-
if keepstate is not None:
2666+
2667+
finally:
25862668
keepstate.restore()
2669+
2670+
# Set up readline for cmd2
2671+
if rl_type != RlType.NONE:
2672+
# Save py's history
2673+
self.py_history.clear()
2674+
for i in range(1, readline.get_current_history_length() + 1):
2675+
self.py_history.append(readline.get_history_item(i))
2676+
2677+
readline.clear_history()
2678+
2679+
# Restore cmd2's history
2680+
for item in saved_cmd2_history:
2681+
readline.add_history(item)
2682+
2683+
if self.use_rawinput and self.completekey:
2684+
# Restore cmd2's tab completion settings
2685+
readline.set_completer(old_completer)
2686+
readline.set_completer_delims(old_delims)
2687+
2688+
if rl_type == RlType.GNU:
2689+
rl_basic_quote_characters.value = old_basic_quotes
2690+
2691+
if 'gnureadline' in sys.modules:
2692+
# Restore what the readline module pointed to
2693+
if saved_readline is None:
2694+
del(sys.modules['readline'])
2695+
else:
2696+
sys.modules['readline'] = saved_readline
2697+
25872698
except Exception:
25882699
pass
25892700
finally:

cmd2/rl_utils.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,38 @@ class RlType(Enum):
3333
if 'pyreadline' in sys.modules:
3434
rl_type = RlType.PYREADLINE
3535

36+
############################################################################################################
37+
# pyreadline is incomplete in terms of the Python readline API. Add the missing functions we need.
38+
############################################################################################################
39+
# readline.redisplay()
40+
try:
41+
getattr(readline, 'redisplay')
42+
except AttributeError:
43+
# noinspection PyProtectedMember
44+
readline.redisplay = readline.rl.mode._update_line
45+
46+
# readline.remove_history_item()
47+
try:
48+
getattr(readline, 'remove_history_item')
49+
except AttributeError:
50+
# noinspection PyProtectedMember
51+
def pyreadline_remove_history_item(pos: int) -> None:
52+
"""
53+
An implementation of remove_history_item() for pyreadline
54+
:param pos: The 0-based position in history to remove
55+
"""
56+
# Save of the current location of the history cursor
57+
saved_cursor = readline.rl.mode._history.history_cursor
58+
59+
# Delete the history item
60+
del(readline.rl.mode._history.history[pos])
61+
62+
# Update the cursor if needed
63+
if saved_cursor > pos:
64+
readline.rl.mode._history.history_cursor -= 1
65+
66+
readline.remove_history_item = pyreadline_remove_history_item
67+
3668
elif 'gnureadline' in sys.modules or 'readline' in sys.modules:
3769
# We don't support libedit
3870
if 'libedit' not in readline.__doc__:

0 commit comments

Comments
 (0)