diff --git a/README.rst b/README.rst index 74ccc85..2250f31 100644 --- a/README.rst +++ b/README.rst @@ -58,6 +58,24 @@ When run, you should expect an output like so: (like ``python my_app.py the_command`` in the above example), your app will function identically to how it did before. +History Configuration +--------------------- + +click-shell uses `readline` to keep track of your command history. By default, it saves up to 1000 commands in ``~/.click-history``. + +You can change this behavior via arguments to ``@shell`` or ``make_click_shell``: + +.. code-block:: python + + @shell(prompt='my-app > ', hist_file='/path/to/my/history', hist_max_len=5000) + def my_app(): + pass + +Alternatively, you can override these values without changing your code by setting environment variables: + +* ``CLICK_SHELL_HIST_FILE``: The location of the history file. +* ``CLICK_SHELL_HIST_MAX_LEN``: The maximum number of items to keep in history. + For more advanced usage, check out our docs at https://click-shell.readthedocs.io/ diff --git a/click_shell/cmd.py b/click_shell/cmd.py index 80fdf00..86961d5 100644 --- a/click_shell/cmd.py +++ b/click_shell/cmd.py @@ -6,6 +6,7 @@ import inspect import os +import re from cmd import Cmd from typing import Any, Callable, List, Optional, Union @@ -34,6 +35,7 @@ def __init__( self, ctx: Optional[click.Context] = None, hist_file: Optional[str] = None, + hist_max_len: int = 1000, *args, **kwargs, ): @@ -50,8 +52,17 @@ def __init__( self.ctx: Optional[click.Context] = ctx # Set the history file - hist_file = hist_file or os.path.join(os.path.expanduser('~'), '.click-history') + hist_file = hist_file or os.environ.get('CLICK_SHELL_HIST_FILE') or os.path.join(os.path.expanduser('~'), '.click-history') self.hist_file: str = os.path.abspath(hist_file) + + env_hist_max_len = os.environ.get('CLICK_SHELL_HIST_MAX_LEN') + if env_hist_max_len is not None: + try: + self.hist_max_len: int = int(env_hist_max_len) + except ValueError: + self.hist_max_len: int = hist_max_len + else: + self.hist_max_len: int = hist_max_len # Make the parent directory if not os.path.isdir(os.path.dirname(self.hist_file)): @@ -68,7 +79,7 @@ def preloop(self): def postloop(self): # Write our history if readline: - readline.set_history_length(1000) + readline.set_history_length(self.hist_max_len) try: readline.write_history_file(self.hist_file) except IOError: @@ -121,15 +132,37 @@ def cmdloop(self, intro: str = None): # pylint: disable=too-many-branches readline.set_completer_delims(self.old_delims) def get_prompt(self) -> Optional[str]: + prompt = None if callable(self.prompt): kwargs = {} if hasattr(inspect, 'signature'): sig = inspect.signature(self.prompt) if 'ctx' in sig.parameters: kwargs['ctx'] = self.ctx - return self.prompt(**kwargs) + prompt = self.prompt(**kwargs) else: - return self.prompt + prompt = self.prompt + + return self._fix_prompt(prompt) + + def _fix_prompt(self, prompt: Optional[str]) -> Optional[str]: + if not prompt or not readline: + return prompt + + # Don't wrap if we're using pyreadline + if 'pyreadline' in getattr(readline, '__name__', ''): + return prompt + + # Readline has a bug where it fails to clear the reverse-i-search prompt + # if the prompt ends with invisible characters (like a color reset code). + # We swap trailing spaces with the reset code so the prompt ends with a space. + prompt = re.sub(r'( +)(\x1b\[0m)$', r'\2\1', prompt) + + # This matches most ANSI escape sequences that are not already wrapped with \x01 and \x02 + # See https://github.com/vittorio/click-shell/issues/23 + pattern = r'(? bool: # we don't want to repeat the last command if nothing was typed diff --git a/click_shell/core.py b/click_shell/core.py index 2ab46f9..59582ae 100644 --- a/click_shell/core.py +++ b/click_shell/core.py @@ -128,6 +128,7 @@ def make_click_shell( prompt: Optional[Union[str, Callable[[], str], Callable[[click.Context, str], str]]] = None, intro: Optional[str] = None, hist_file: Optional[str] = None, + hist_max_len: int = 1000, shell_cls: Type[ClickShell] = ClickShell, ) -> ClickShell: assert isinstance(ctx, click.Context) @@ -137,7 +138,7 @@ def make_click_shell( ctx.info_name = None # Create our shell object - shell = shell_cls(ctx=ctx, hist_file=hist_file) + shell = shell_cls(ctx=ctx, hist_file=hist_file, hist_max_len=hist_max_len) if prompt is not None: shell.prompt = prompt @@ -160,6 +161,7 @@ def __init__( prompt: Optional[Union[str, Callable[..., str]]] = None, intro: Optional[str] = None, hist_file: Optional[str] = None, + hist_max_len: int = 1000, on_finished: Optional[Callable[[click.Context], None]] = None, shell_cls: Type[ClickShell] = ClickShell, **attrs @@ -168,7 +170,7 @@ def __init__( super().__init__(**attrs) # Make our shell - self.shell: ClickShell = shell_cls(hist_file=hist_file) + self.shell: ClickShell = shell_cls(hist_file=hist_file, hist_max_len=hist_max_len) self.on_finished: Optional[Callable[[click.Context], None]] = on_finished if prompt: self.shell.prompt = prompt diff --git a/docs/usage.rst b/docs/usage.rst index d19dc21..cb668b0 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -51,7 +51,9 @@ When run with the above arguments, you should expect an output like so: - ``intro`` - this will get printed once when the shell first starts Defaults to ``None``, meaning nothing gets printed - ``hist_file`` - this is the location of the history file used by the shell. - Defaults to ``'~/.click-history'`` + Defaults to ``'~/.click-history'``. Can also be overridden with the ``CLICK_SHELL_HIST_FILE`` environment variable. +- ``hist_max_len`` - the maximum number of commands to save in the history file. + Defaults to ``1000``. Can also be overridden with the ``CLICK_SHELL_HIST_MAX_LEN`` environment variable. - ``on_finished`` - a callable that will be called when the shell exits. You can use it to clean up any resources that may need cleaning up. - ``shell_cls`` - a subclass of ``click_shell.ClickShell`` used to build the shell. @@ -88,5 +90,5 @@ object and start it up: The first argument passed to ``make_click_shell`` must be the root level context object for -your click application. The other 3 args (prompt, intro, hist_file, shell_cls) are the same as described +your click application. The other 4 args (prompt, intro, hist_file, hist_max_len, shell_cls) are the same as described above under the Decorator section. diff --git a/tests/test_ansi_wrapping.py b/tests/test_ansi_wrapping.py new file mode 100644 index 0000000..6f62812 --- /dev/null +++ b/tests/test_ansi_wrapping.py @@ -0,0 +1,87 @@ +import re +from click_shell.cmd import ClickCmd + +def test_fix_prompt_no_readline(): + # Simulate no readline + import click_shell.cmd + original_readline = click_shell.cmd.readline + click_shell.cmd.readline = None + try: + cmd = ClickCmd() + prompt = '\x1b[34mmy-shell> \x1b[0m' + assert cmd._fix_prompt(prompt) == prompt + finally: + click_shell.cmd.readline = original_readline + +def test_fix_prompt_with_ansi(): + # We need to make sure readline is "present" for the test + import click_shell.cmd + original_readline = click_shell.cmd.readline + if click_shell.cmd.readline is None: + # mock it + class MockReadline: + __name__ = 'readline' + click_shell.cmd.readline = MockReadline() + + try: + cmd = ClickCmd() + prompt = '\x1b[34mmy-shell> \x1b[0m' + fixed = cmd._fix_prompt(prompt) + assert fixed == '\x01\x1b[34m\x02my-shell>\x01\x1b[0m\x02 ' + finally: + click_shell.cmd.readline = original_readline + +def test_fix_prompt_already_wrapped(): + import click_shell.cmd + original_readline = click_shell.cmd.readline + if click_shell.cmd.readline is None: + class MockReadline: + __name__ = 'readline' + click_shell.cmd.readline = MockReadline() + + try: + cmd = ClickCmd() + prompt = '\x01\x1b[34m\x02my-shell> \x01\x1b[0m\x02' + fixed = cmd._fix_prompt(prompt) + assert fixed == prompt + finally: + click_shell.cmd.readline = original_readline + +def test_fix_prompt_pyreadline(): + import click_shell.cmd + original_readline = click_shell.cmd.readline + + # Mock pyreadline + class MockPyReadline: + __name__ = 'pyreadline' + click_shell.cmd.readline = MockPyReadline() + + try: + cmd = ClickCmd() + prompt = '\x1b[34mmy-shell> \x1b[0m' + assert cmd._fix_prompt(prompt) == prompt + finally: + click_shell.cmd.readline = original_readline + +def test_fix_prompt_trailing_space(): + import click_shell.cmd + original_readline = click_shell.cmd.readline + if click_shell.cmd.readline is None: + class MockReadline: + __name__ = 'readline' + click_shell.cmd.readline = MockReadline() + + try: + cmd = ClickCmd() + # click.style typically places the reset code at the very end + prompt = '\x1b[34mmy-shell> \x1b[0m' + fixed = cmd._fix_prompt(prompt) + # the space should be moved outside the \x02 wrapper + assert fixed == '\x01\x1b[34m\x02my-shell>\x01\x1b[0m\x02 ' + + # Test with multiple trailing spaces + prompt2 = '\x1b[34mmy-shell> \x1b[0m' + fixed2 = cmd._fix_prompt(prompt2) + assert fixed2 == '\x01\x1b[34m\x02my-shell>\x01\x1b[0m\x02 ' + finally: + click_shell.cmd.readline = original_readline