Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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/

Expand Down
41 changes: 37 additions & 4 deletions click_shell/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import inspect
import os
import re
from cmd import Cmd
from typing import Any, Callable, List, Optional, Union

Expand Down Expand Up @@ -34,6 +35,7 @@ def __init__(
self,
ctx: Optional[click.Context] = None,
hist_file: Optional[str] = None,
hist_max_len: int = 1000,
*args,
**kwargs,
):
Expand All @@ -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)):
Expand All @@ -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:
Expand Down Expand Up @@ -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'(?<!\x01)(\x1b\[[0-9;]*[a-zA-Z])(?!\x02)'

return re.sub(pattern, lambda m: '\x01' + m.group(1) + '\x02', prompt)

def emptyline(self) -> bool:
# we don't want to repeat the last command if nothing was typed
Expand Down
6 changes: 4 additions & 2 deletions click_shell/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions docs/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
87 changes: 87 additions & 0 deletions tests/test_ansi_wrapping.py
Original file line number Diff line number Diff line change
@@ -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