mirror of
https://github.com/certbot/certbot.git
synced 2026-06-08 16:22:18 -04:00
Merge pull request #2708 from letsencrypt/dselect
Add directory_select method to IDisplay
This commit is contained in:
commit
11772b8436
6 changed files with 283 additions and 5 deletions
61
letsencrypt/display/completer.py
Normal file
61
letsencrypt/display/completer.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Provides Tab completion when prompting users for a path."""
|
||||
import glob
|
||||
# readline module is not available on all systems
|
||||
try:
|
||||
import readline
|
||||
except ImportError:
|
||||
import letsencrypt.display.dummy_readline as readline
|
||||
|
||||
|
||||
class Completer(object):
|
||||
"""Provides Tab completion when prompting users for a path.
|
||||
|
||||
This class is meant to be used with readline to provide Tab
|
||||
completion for users entering paths. The complete method can be
|
||||
passed to readline.set_completer directly, however, this function
|
||||
works best as a context manager. For example:
|
||||
|
||||
with Completer():
|
||||
raw_input()
|
||||
|
||||
In this example, Tab completion will be available during the call to
|
||||
raw_input above, however, readline will be restored to its previous
|
||||
state when exiting the body of the with statement.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._iter = self._original_completer = self._original_delims = None
|
||||
|
||||
def complete(self, text, state):
|
||||
"""Provides path completion for use with readline.
|
||||
|
||||
:param str text: text to offer completions for
|
||||
:param int state: which completion to return
|
||||
|
||||
:returns: possible completion for text or ``None`` if all
|
||||
completions have been returned
|
||||
:rtype: str
|
||||
|
||||
"""
|
||||
if state == 0:
|
||||
self._iter = glob.iglob(text + '*')
|
||||
return next(self._iter, None)
|
||||
|
||||
def __enter__(self):
|
||||
self._original_completer = readline.get_completer()
|
||||
self._original_delims = readline.get_completer_delims()
|
||||
|
||||
readline.set_completer(self.complete)
|
||||
readline.set_completer_delims(' \t\n;')
|
||||
|
||||
# readline can be implemented using GNU readline or libedit
|
||||
# which have different configuration syntax
|
||||
if 'libedit' in readline.__doc__:
|
||||
readline.parse_and_bind('bind ^I rl_complete')
|
||||
else:
|
||||
readline.parse_and_bind('tab: complete')
|
||||
|
||||
def __exit__(self, unused_type, unused_value, unused_traceback):
|
||||
readline.set_completer_delims(self._original_delims)
|
||||
readline.set_completer(self._original_completer)
|
||||
21
letsencrypt/display/dummy_readline.py
Normal file
21
letsencrypt/display/dummy_readline.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""A dummy module with no effect for use on systems without readline."""
|
||||
|
||||
|
||||
def get_completer():
|
||||
"""An empty implementation of readline.get_completer."""
|
||||
|
||||
|
||||
def get_completer_delims():
|
||||
"""An empty implementation of readline.get_completer_delims."""
|
||||
|
||||
|
||||
def parse_and_bind(unused_command):
|
||||
"""An empty implementation of readline.parse_and_bind."""
|
||||
|
||||
|
||||
def set_completer(unused_function=None):
|
||||
"""An empty implementation of readline.set_completer."""
|
||||
|
||||
|
||||
def set_completer_delims(unused_delims):
|
||||
"""An empty implementation of readline.set_completer_delims."""
|
||||
|
|
@ -7,10 +7,18 @@ import zope.interface
|
|||
|
||||
from letsencrypt import interfaces
|
||||
from letsencrypt import errors
|
||||
from letsencrypt.display import completer
|
||||
|
||||
WIDTH = 72
|
||||
HEIGHT = 20
|
||||
|
||||
DSELECT_HELP = (
|
||||
"Use the arrow keys or Tab to move between window elements. Space can be "
|
||||
"used to complete the input path with the selected element in the "
|
||||
"directory window. Pressing enter will select the currently highlighted "
|
||||
"button.")
|
||||
"""Help text on how to use dialog's dselect."""
|
||||
|
||||
# Display exit codes
|
||||
OK = "ok"
|
||||
"""Display exit code indicating user acceptance."""
|
||||
|
|
@ -21,6 +29,7 @@ CANCEL = "cancel"
|
|||
HELP = "help"
|
||||
"""Display exit code when for when the user requests more help."""
|
||||
|
||||
|
||||
def _wrap_lines(msg):
|
||||
"""Format lines nicely to 80 chars.
|
||||
|
||||
|
|
@ -36,6 +45,7 @@ def _wrap_lines(msg):
|
|||
fixed_l.append(textwrap.fill(line, 80))
|
||||
return os.linesep.join(fixed_l)
|
||||
|
||||
|
||||
@zope.interface.implementer(interfaces.IDisplay)
|
||||
class NcursesDisplay(object):
|
||||
"""Ncurses-based display."""
|
||||
|
|
@ -118,7 +128,6 @@ class NcursesDisplay(object):
|
|||
|
||||
return code, int(index) - 1
|
||||
|
||||
|
||||
def input(self, message, **unused_kwargs):
|
||||
"""Display an input box to the user.
|
||||
|
||||
|
|
@ -132,11 +141,10 @@ class NcursesDisplay(object):
|
|||
"""
|
||||
sections = message.split("\n")
|
||||
# each section takes at least one line, plus extras if it's longer than self.width
|
||||
wordlines = [1 + (len(section)/self.width) for section in sections]
|
||||
wordlines = [1 + (len(section) / self.width) for section in sections]
|
||||
height = 6 + sum(wordlines) + len(sections)
|
||||
return self.dialog.inputbox(message, width=self.width, height=height)
|
||||
|
||||
|
||||
def yesno(self, message, yes_label="Yes", no_label="No", **unused_kwargs):
|
||||
"""Display a Yes/No dialog box.
|
||||
|
||||
|
|
@ -174,6 +182,21 @@ class NcursesDisplay(object):
|
|||
return self.dialog.checklist(
|
||||
message, width=self.width, height=self.height, choices=choices)
|
||||
|
||||
def directory_select(self, message, **unused_kwargs):
|
||||
"""Display a directory selection screen.
|
||||
|
||||
:param str message: prompt to give the user
|
||||
|
||||
:returns: tuple of the form (`code`, `string`) where
|
||||
`code` - int display exit code
|
||||
`string` - input entered by the user
|
||||
|
||||
"""
|
||||
root_directory = os.path.abspath(os.sep)
|
||||
return self.dialog.dselect(
|
||||
filepath=root_directory, width=self.width,
|
||||
height=self.height, help_button=True, title=message)
|
||||
|
||||
|
||||
@zope.interface.implementer(interfaces.IDisplay)
|
||||
class FileDisplay(object):
|
||||
|
|
@ -317,6 +340,19 @@ class FileDisplay(object):
|
|||
else:
|
||||
return code, []
|
||||
|
||||
def directory_select(self, message, **unused_kwargs):
|
||||
"""Display a directory selection screen.
|
||||
|
||||
:param str message: prompt to give the user
|
||||
|
||||
:returns: tuple of the form (`code`, `string`) where
|
||||
`code` - int display exit code
|
||||
`string` - input entered by the user
|
||||
|
||||
"""
|
||||
with completer.Completer():
|
||||
return self.input(message)
|
||||
|
||||
def _scrub_checklist_input(self, indices, tags):
|
||||
# pylint: disable=no-self-use
|
||||
"""Validate input and transform indices to appropriate tags.
|
||||
|
|
@ -373,7 +409,6 @@ class FileDisplay(object):
|
|||
|
||||
self.outfile.write(side_frame)
|
||||
|
||||
|
||||
def _get_valid_int_ans(self, max_):
|
||||
"""Get a numerical selection.
|
||||
|
||||
|
|
@ -409,6 +444,7 @@ class FileDisplay(object):
|
|||
|
||||
return OK, selection
|
||||
|
||||
|
||||
@zope.interface.implementer(interfaces.IDisplay)
|
||||
class NoninteractiveDisplay(object):
|
||||
"""An iDisplay implementation that never asks for interactive user input"""
|
||||
|
|
@ -483,7 +519,6 @@ class NoninteractiveDisplay(object):
|
|||
else:
|
||||
return OK, default
|
||||
|
||||
|
||||
def yesno(self, message, yes_label=None, no_label=None, default=None, cli_flag=None):
|
||||
# pylint: disable=unused-argument
|
||||
"""Decide Yes or No, without asking anybody
|
||||
|
|
@ -520,6 +555,25 @@ class NoninteractiveDisplay(object):
|
|||
else:
|
||||
return OK, default
|
||||
|
||||
def directory_select(self, message, default=None, cli_flag=None):
|
||||
"""Simulate prompting the user for a directory.
|
||||
|
||||
This function returns default if it is not ``None``, otherwise,
|
||||
an exception is raised explaining the problem. If cli_flag is
|
||||
not ``None``, the error message will include the flag that can
|
||||
be used to set this value with the CLI.
|
||||
|
||||
:param str message: prompt to give the user
|
||||
:param default: default value to return (if one exists)
|
||||
:param str cli_flag: option used to set this value with the CLI
|
||||
|
||||
:returns: tuple of the form (`code`, `string`) where
|
||||
`code` - int display exit code
|
||||
`string` - input entered by the user
|
||||
|
||||
"""
|
||||
return self.input(message, default, cli_flag)
|
||||
|
||||
|
||||
def separate_list_input(input_):
|
||||
"""Separate a comma or space separated list.
|
||||
|
|
|
|||
|
|
@ -443,6 +443,22 @@ class IDisplay(zope.interface.Interface):
|
|||
|
||||
"""
|
||||
|
||||
def directory_select(self, message, default=None, cli_flag=None):
|
||||
"""Display a directory selection screen.
|
||||
|
||||
:param str message: prompt to give the user
|
||||
:param default: the default value to return, if one exists, when
|
||||
using the NoninteractiveDisplay
|
||||
:param str cli_flag: option used to set this value with the CLI,
|
||||
if one exists, to be included in error messages given by
|
||||
NoninteractiveDisplay
|
||||
|
||||
:returns: tuple of the form (`code`, `string`) where
|
||||
`code` - int display exit code
|
||||
`string` - input entered by the user
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class IValidator(zope.interface.Interface):
|
||||
"""Configuration validator."""
|
||||
|
|
|
|||
102
letsencrypt/tests/display/completer_test.py
Normal file
102
letsencrypt/tests/display/completer_test.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""Test letsencrypt.display.completer."""
|
||||
import os
|
||||
import readline
|
||||
import shutil
|
||||
import string
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import mock
|
||||
from six.moves import reload_module # pylint: disable=import-error
|
||||
|
||||
|
||||
class CompleterTest(unittest.TestCase):
|
||||
"""Test letsencrypt.display.completer.Completer."""
|
||||
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
|
||||
# directories must end with os.sep for completer to
|
||||
# search inside the directory for possible completions
|
||||
if self.temp_dir[-1] != os.sep:
|
||||
self.temp_dir += os.sep
|
||||
|
||||
self.paths = []
|
||||
# create some files and directories in temp_dir
|
||||
for c in string.ascii_lowercase:
|
||||
path = os.path.join(self.temp_dir, c)
|
||||
self.paths.append(path)
|
||||
if ord(c) % 2:
|
||||
os.mkdir(path)
|
||||
else:
|
||||
with open(path, 'w'):
|
||||
pass
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_complete(self):
|
||||
from letsencrypt.display import completer
|
||||
my_completer = completer.Completer()
|
||||
num_paths = len(self.paths)
|
||||
|
||||
for i in range(num_paths):
|
||||
completion = my_completer.complete(self.temp_dir, i)
|
||||
self.assertTrue(completion in self.paths)
|
||||
self.paths.remove(completion)
|
||||
|
||||
self.assertFalse(self.paths)
|
||||
completion = my_completer.complete(self.temp_dir, num_paths)
|
||||
self.assertEqual(completion, None)
|
||||
|
||||
def test_import_error(self):
|
||||
original_readline = sys.modules['readline']
|
||||
sys.modules['readline'] = None
|
||||
|
||||
self.test_context_manager_with_unmocked_readline()
|
||||
|
||||
sys.modules['readline'] = original_readline
|
||||
|
||||
def test_context_manager_with_unmocked_readline(self):
|
||||
from letsencrypt.display import completer
|
||||
reload_module(completer)
|
||||
|
||||
original_completer = readline.get_completer()
|
||||
original_delims = readline.get_completer_delims()
|
||||
|
||||
with completer.Completer():
|
||||
pass
|
||||
|
||||
self.assertEqual(readline.get_completer(), original_completer)
|
||||
self.assertEqual(readline.get_completer_delims(), original_delims)
|
||||
|
||||
@mock.patch('letsencrypt.display.completer.readline', autospec=True)
|
||||
def test_context_manager_libedit(self, mock_readline):
|
||||
mock_readline.__doc__ = 'libedit'
|
||||
self._test_context_manager_with_mock_readline(mock_readline)
|
||||
|
||||
@mock.patch('letsencrypt.display.completer.readline', autospec=True)
|
||||
def test_context_manager_readline(self, mock_readline):
|
||||
mock_readline.__doc__ = 'GNU readline'
|
||||
self._test_context_manager_with_mock_readline(mock_readline)
|
||||
|
||||
def _test_context_manager_with_mock_readline(self, mock_readline):
|
||||
from letsencrypt.display import completer
|
||||
|
||||
mock_readline.parse_and_bind.side_effect = enable_tab_completion
|
||||
|
||||
with completer.Completer():
|
||||
pass
|
||||
|
||||
self.assertTrue(mock_readline.parse_and_bind.called)
|
||||
|
||||
|
||||
def enable_tab_completion(unused_command):
|
||||
"""Enables readline tab completion using the system specific syntax."""
|
||||
libedit = 'libedit' in readline.__doc__
|
||||
command = 'bind ^I rl_complete' if libedit else 'tab: complete'
|
||||
readline.parse_and_bind(command)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main() # pragma: no cover
|
||||
|
|
@ -123,6 +123,11 @@ class NcursesDisplayTest(unittest.TestCase):
|
|||
"message", width=display_util.WIDTH, height=display_util.HEIGHT,
|
||||
choices=choices)
|
||||
|
||||
@mock.patch("letsencrypt.display.util.dialog.Dialog.dselect")
|
||||
def test_directory_select(self, mock_dselect):
|
||||
self.displayer.directory_select("message")
|
||||
self.assertEqual(mock_dselect.call_count, 1)
|
||||
|
||||
|
||||
class FileOutputDisplayTest(unittest.TestCase):
|
||||
"""Test stdout display.
|
||||
|
|
@ -227,6 +232,15 @@ class FileOutputDisplayTest(unittest.TestCase):
|
|||
self.displayer._scrub_checklist_input(list_, TAGS))
|
||||
self.assertEqual(set_tags, exp[i])
|
||||
|
||||
@mock.patch("letsencrypt.display.util.FileDisplay.input")
|
||||
def test_directory_select(self, mock_input):
|
||||
message = "msg"
|
||||
result = (display_util.OK, "/var/www/html",)
|
||||
mock_input.return_value = result
|
||||
|
||||
self.assertEqual(self.displayer.directory_select(message), result)
|
||||
mock_input.assert_called_once_with(message)
|
||||
|
||||
def test_scrub_checklist_input_invalid(self):
|
||||
# pylint: disable=protected-access
|
||||
indices = [
|
||||
|
|
@ -280,6 +294,7 @@ class FileOutputDisplayTest(unittest.TestCase):
|
|||
self.displayer._get_valid_int_ans(3),
|
||||
(display_util.CANCEL, -1))
|
||||
|
||||
|
||||
class NoninteractiveDisplayTest(unittest.TestCase):
|
||||
"""Test non-interactive display.
|
||||
|
||||
|
|
@ -320,6 +335,15 @@ class NoninteractiveDisplayTest(unittest.TestCase):
|
|||
self.assertEqual(ret, (display_util.OK, d))
|
||||
self.assertRaises(errors.MissingCommandlineFlag, self.displayer.checklist, "message", TAGS)
|
||||
|
||||
def test_directory_select(self):
|
||||
default = "/var/www/html"
|
||||
expected = (display_util.OK, default)
|
||||
actual = self.displayer.directory_select("msg", default)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
self.assertRaises(
|
||||
errors.MissingCommandlineFlag, self.displayer.directory_select, "msg")
|
||||
|
||||
|
||||
class SeparateListInputTest(unittest.TestCase):
|
||||
"""Test Module functions."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue