diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..31ed35f7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +[*] +charset = utf-8 +insert_final_newline = true + +[*.py] +indent_style = space +indent_size = 4 +max_line_length = 79 + +[Makefile] +indent_style = tab diff --git a/README.rst b/README.rst index 460cdbcd..47e98507 100644 --- a/README.rst +++ b/README.rst @@ -93,6 +93,25 @@ Example: |inquirer checkbox| +Path +-------- + +Like Text question, but with builtin validations for working with paths. + +Example: + +.. code:: python + + + import inquirer + questions = [ + inquirer.Path('log_file', + message="Where logs should be located?", + path_type=inquirer.Path.DIRECTORY, + ), + ] + answers = inquirer.prompt(questions) + License ======= diff --git a/docs/source/inquirer.rst b/docs/source/inquirer.rst index 52dad53b..69db9855 100644 --- a/docs/source/inquirer.rst +++ b/docs/source/inquirer.rst @@ -43,6 +43,22 @@ inquirer.questions module :undoc-members: :show-inheritance: +inquirer.shortcuts module +------------------------- + +.. automodule:: inquirer.shortcuts + :members: + :undoc-members: + :show-inheritance: + +inquirer.themes module +---------------------- + +.. automodule:: inquirer.themes + :members: + :undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 89b36c51..74276ddf 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -12,17 +12,19 @@ Each :code:`Question` require some common arguments. So, you just need to know w Question types -------------- -+-------------+--------------------------------------------------+ -|**TEXT** | Expects a text answer | -+-------------+--------------------------------------------------+ -|**PASSWORD** | Do not prompt the answer. | -+-------------+--------------------------------------------------+ -|**CONFIRM** | Requires a boolean answer | -+-------------+--------------------------------------------------+ -|**LIST** | Show a list and allow to select just one answer. | -+-------------+--------------------------------------------------+ -|**CHECKBOX** | Show a list and allow to select a bunch of them | -+-------------+--------------------------------------------------+ ++-------------+--------------------------------------------------------+ +|**TEXT** | Expects a text answer. | ++-------------+--------------------------------------------------------+ +|**PASSWORD** | Do not prompt the answer. | ++-------------+--------------------------------------------------------+ +|**CONFIRM** | Requires a boolean answer. | ++-------------+--------------------------------------------------------+ +|**LIST** | Show a list and allow to select just one answer. | ++-------------+--------------------------------------------------------+ +|**CHECKBOX** | Show a list and allow to select a bunch of them. | ++-------------+--------------------------------------------------------+ +|**PATH** | Requires valid path and allows additional validations. | ++-------------+--------------------------------------------------------+ There are pictures of some of them in the :ref:`examples` section. @@ -139,6 +141,56 @@ It's value is `boolean` or a `function` with the sign: where ``answers`` contains the `dict` of previous answers again. +Path Question +------------- + +Path Question accepts any valid path which can be both absolute or relative. +By default it only validates the validity of the path. Except of validation +it return normalized path and it expands home alias (~). + +The Path Question have additional arguments for validating paths. + +path_type +~~~~~~~~~ + +Validation argument that enables to enforce if the path should be aiming +to file (``Path.FILE``) or directory (``Path.DIRECTORY``). + +By default nothing is enforced (``Path.ANY``). + +.. code:: python + + Path('log_file', 'Where should be log files located?', path_type=Path.DIRECTORY) + + +exists +~~~~~~ + +Validation argument that enables to enforce if the provided path should +or should not exists. Expects ``True`` if the path should +exists, or ``False`` if the path should not exists. + +By default nothing is enforced (``None``) + +.. code:: python + + Path('config_file', 'Point me to your configuration file.', exists=True, path_type=Path.File) + + +normalize_to_absolute_path +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Argument which will enable normalization on the provided path. When enabled, in case of relative path would be provided +the Question will normalize it to absolute path. + +Expects ``bool`` value. Default ``False``. + +.. code:: python + + Path('config_file', 'Point me to your configuration file.', normalize_to_absolute_path=True) + + + Creating the Question object ---------------------------- diff --git a/inquirer/__init__.py b/inquirer/__init__.py index 0503eded..af5e4115 100644 --- a/inquirer/__init__.py +++ b/inquirer/__init__.py @@ -4,12 +4,12 @@ try: from .prompt import prompt - from .questions import Text, Password, Confirm, List, Checkbox, \ - load_from_dict, load_from_json + from .questions import Text, Password, Confirm, List, Checkbox, Path, \ + load_from_dict, load_from_json, load_from_list from .shortcuts import text, password, confirm, list_input, checkbox __all__ = ['prompt', 'Text', 'Password', 'Confirm', 'List', 'Checkbox', - 'load_from_list', 'load_from_dict', 'load_from_json', + 'Path', 'load_from_list', 'load_from_dict', 'load_from_json', 'text', 'password', 'confirm', 'list_input', 'checkbox'] except ImportError as e: print("An error was found, but returning just with the version: %s" % e) diff --git a/inquirer/questions.py b/inquirer/questions.py index 90ee3378..c2ad6a40 100644 --- a/inquirer/questions.py +++ b/inquirer/questions.py @@ -4,12 +4,15 @@ """ import json +import os +import errno +import sys from . import errors def question_factory(kind, *args, **kwargs): - for clazz in (Text, Password, Confirm, List, Checkbox): + for clazz in (Text, Password, Confirm, List, Checkbox, Path): if clazz.kind == kind: return clazz(*args, **kwargs) raise errors.UnknownQuestionTypeError() @@ -169,3 +172,106 @@ def __init__(self, class Checkbox(Question): kind = 'checkbox' + + +# Solution for checking valid path based on +# https://stackoverflow.com/a/34102855/1360532 +ERROR_INVALID_NAME = 123 + + +def is_pathname_valid(pathname): + """ + `True` if the passed pathname is a valid pathname for the current OS; + `False` otherwise. + """ + try: + if not isinstance(pathname, str) or not pathname: + return False + + _, pathname = os.path.splitdrive(pathname) + + root_dirname = os.environ.get('HOMEDRIVE', 'C:') \ + if sys.platform == 'win32' else os.path.sep + + assert os.path.isdir(root_dirname) + root_dirname = root_dirname.rstrip(os.path.sep) + os.path.sep + + for pathname_part in pathname.split(os.path.sep): + try: + os.lstat(root_dirname + pathname_part) + except OSError as exc: + if hasattr(exc, 'winerror'): + if exc.winerror == ERROR_INVALID_NAME: + return False + elif exc.errno in {errno.ENAMETOOLONG, errno.ERANGE}: + return False + except (ValueError, TypeError): + return False + else: + return True + + +class Path(Text): + ANY = 'any' + FILE = 'file' + DIRECTORY = 'directory' + + kind = 'path' + + def __init__(self, name, default=None, path_type='any', exists=None, + normalize_to_absolute_path=False, **kwargs): + super(Path, self).__init__(name, default=default, **kwargs) + + self._path_type = path_type + self._exists = exists + self._normalize_to_absolute_path = normalize_to_absolute_path + + if default is not None: + try: + self.validate(default) + except errors.ValidationError: + raise ValueError("Default value '{}' is not valid based on " + "your Path's criteria".format(default)) + + def validate(self, current): + super(Path, self).validate(current) + + if current is None: + raise errors.ValidationError(current) + + if not is_pathname_valid(current): + raise errors.ValidationError(current) + + # os.path.isdir and isfile check also existence of the path, + # which might not be desirable + if self._path_type == 'file': + if self._exists is None and os.path.basename(current) == '': + raise errors.ValidationError(current) + elif self._exists and not os.path.isfile(current): + raise errors.ValidationError(current) + elif self._exists is not None and not self._exists \ + and os.path.isfile(current): + raise errors.ValidationError(current) + + elif self._path_type == 'directory': + if self._exists is None and os.path.basename(current) != '': + raise errors.ValidationError(current) + elif self._exists and not os.path.isdir(current): + raise errors.ValidationError(current) + elif self._exists is not None and not self._exists \ + and os.path.isdir(current): + raise errors.ValidationError(current) + + elif self._exists and not os.path.exists(current): + raise errors.ValidationError(current) + elif self._exists is not None and not self._exists \ + and os.path.exists(current): + raise errors.ValidationError(current) + + def normalize_value(self, value): + value = os.path.expanduser(value) + + if self._normalize_to_absolute_path: + value = os.path.abspath(value) + + return value diff --git a/inquirer/render/console/__init__.py b/inquirer/render/console/__init__.py index e46a28f9..a3ede23d 100644 --- a/inquirer/render/console/__init__.py +++ b/inquirer/render/console/__init__.py @@ -13,6 +13,7 @@ from ._confirm import Confirm from ._list import List from ._checkbox import Checkbox +from ._path import Path class ConsoleRender(object): @@ -152,6 +153,7 @@ def render_factory(self, question_type): 'confirm': Confirm, 'list': List, 'checkbox': Checkbox, + 'path': Path, } if question_type not in matrix: diff --git a/inquirer/render/console/_path.py b/inquirer/render/console/_path.py new file mode 100644 index 00000000..5ca747d6 --- /dev/null +++ b/inquirer/render/console/_path.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- + +from readchar import key +from .base import BaseConsoleRender +from inquirer import errors + + +class Path(BaseConsoleRender): + title_inline = True + + def __init__(self, *args, **kwargs): + super(Path, self).__init__(*args, **kwargs) + self.current = self.question.default or '' + + def get_current_value(self): + return self.current + + def process_input(self, pressed): + if pressed == key.CTRL_C: + raise KeyboardInterrupt() + + if pressed in (key.CR, key.LF, key.ENTER): + raise errors.EndOfInput( + self.question.normalize_value(self.current) + ) + + if pressed == key.BACKSPACE: + if len(self.current): + self.current = self.current[:-1] + return + + if len(pressed) != 1: + return + + self.current += pressed diff --git a/inquirer/shortcuts.py b/inquirer/shortcuts.py index 4ef93d81..a8618c80 100644 --- a/inquirer/shortcuts.py +++ b/inquirer/shortcuts.py @@ -30,3 +30,9 @@ def checkbox(message, render=None, **kwargs): render = render or ConsoleRender() question = questions.Checkbox(name='', message=message, **kwargs) return render.render(question) + + +def path(message, render=None, **kwargs): + render = render or ConsoleRender() + question = questions.Path(name='', message=message, **kwargs) + return render.render(question) diff --git a/tests/unit/test_prompt.py b/tests/unit/test_prompt.py index 21cff048..bda19b7e 100644 --- a/tests/unit/test_prompt.py +++ b/tests/unit/test_prompt.py @@ -1,6 +1,6 @@ import unittest try: - from unittest import MagicMock, Mock + from unittest.mock import MagicMock, Mock except ImportError: from mock import MagicMock, Mock @@ -21,5 +21,5 @@ def test_prompt_renders_a_questions(self): result = prompt([question1], render=render) self.assertEquals({'foo': result1}, result) - render.render.assert_called() + self.assertTrue(render.render.called) render.render.call_args_list[0][0] == result1 diff --git a/tests/unit/test_question.py b/tests/unit/test_question.py index 3e00f089..fa025dc8 100644 --- a/tests/unit/test_question.py +++ b/tests/unit/test_question.py @@ -1,4 +1,8 @@ # encoding: utf-8 + +import os +import shutil +import tempfile import unittest from inquirer import questions @@ -121,6 +125,7 @@ def test_validate_function_returning_true_ends_ok(self): def test_validate_function_raising_exception(self): def raise_exc(x, y): raise Exception('foo') + name = 'foo' q = questions.Question(name, validate=raise_exc) @@ -132,6 +137,7 @@ def test_validate_function_receives_object(self): def compare(x, y): return expected == y + name = 'foo' q = questions.Question(name, validate=compare) @@ -228,3 +234,128 @@ def test_default_default_value_is_false_instead_of_none(self): q = questions.Confirm(name) self.assertEquals(False, q.default) + + +class TestPathQuestion(unittest.TestCase): + def test_path_validation(self): + def do_test(path, result=True): + q = questions.Path('validation_test') + if result: + self.assertIsNone(q.validate(path)) + else: + with self.assertRaises(errors.ValidationError): + q.validate(path) + + do_test(None, False) + + if os.environ.get('TRAVIS_PYTHON_VERSION') != 'pypy3': + # Path component must not be longer then 255 bytes + do_test('a' * 256, False) + do_test('/asdf/' + 'a' * 256, False) + do_test('{}/{}'.format('a' * 255, 'b' * 255), True) + + # Path component must not contains null bytes + do_test('some/path/with/{}byte'.format(b'\x00'.decode('utf-8')), + False) + + def test_path_type_validation_no_existence_check(self): + def do_test(path_type, path, result=True): + q = questions.Path('path_type_test', path_type=path_type) + if result: + self.assertIsNone(q.validate(path)) + else: + with self.assertRaises(errors.ValidationError): + q.validate(path) + + do_test(questions.Path.ANY, './aa/bb') + do_test(questions.Path.ANY, './aa/') + do_test(questions.Path.ANY, 'aa/bb') + do_test(questions.Path.ANY, 'aa/') + do_test(questions.Path.ANY, '/aa/') + do_test(questions.Path.ANY, '/aa/bb') + do_test(questions.Path.ANY, '~/aa/bb') + + do_test(questions.Path.FILE, './aa/bb') + do_test(questions.Path.FILE, './aa/', False) + do_test(questions.Path.FILE, 'aa/bb') + do_test(questions.Path.FILE, 'aa/', False) + do_test(questions.Path.FILE, '/aa/', False) + do_test(questions.Path.FILE, '~/aa/', False) + do_test(questions.Path.FILE, '/aa/bb') + do_test(questions.Path.FILE, '~/aa/.bb') + + do_test(questions.Path.DIRECTORY, './aa/bb', False) + do_test(questions.Path.DIRECTORY, './aa/') + do_test(questions.Path.DIRECTORY, 'aa/bb', False) + do_test(questions.Path.DIRECTORY, 'aa/') + do_test(questions.Path.DIRECTORY, '/aa/') + do_test(questions.Path.DIRECTORY, '~/aa/') + do_test(questions.Path.DIRECTORY, '/aa/bb', False) + do_test(questions.Path.DIRECTORY, '~/aa/bb', False) + + def test_path_type_validation_existing(self): + root = tempfile.mkdtemp() + some_existing_dir = os.path.join(root, 'some_dir') + some_non_existing_dir = os.path.join(root, 'some_non_existing_dir') + some_existing_file = os.path.join(root, 'some_file') + some_non_existing_file = os.path.join(root, 'some_non_existing_file') + + os.mkdir(some_existing_dir) + open(some_existing_file, 'a').close() + + def do_test(path_type, path, exists, result=True): + q = questions.Path('path_type_test', exists=exists, + path_type=path_type) + if result: + self.assertIsNone(q.validate(path)) + else: + with self.assertRaises(errors.ValidationError): + q.validate(path) + + try: + do_test(questions.Path.ANY, some_existing_file, True, True) + do_test(questions.Path.ANY, some_non_existing_file, True, False) + do_test(questions.Path.ANY, some_existing_file, False, False) + do_test(questions.Path.ANY, some_non_existing_file, False, True) + do_test(questions.Path.ANY, some_existing_dir, True, True) + do_test(questions.Path.ANY, some_non_existing_dir, True, False) + do_test(questions.Path.ANY, some_existing_dir, False, False) + do_test(questions.Path.ANY, some_non_existing_dir, False, True) + + do_test(questions.Path.FILE, some_existing_file, True, True) + do_test(questions.Path.FILE, some_non_existing_file, True, False) + do_test(questions.Path.FILE, some_non_existing_file, False, True) + do_test(questions.Path.FILE, some_existing_file, False, False) + + do_test(questions.Path.DIRECTORY, + some_existing_dir, True, True) + do_test(questions.Path.DIRECTORY, + some_non_existing_dir, True, False) + do_test(questions.Path.DIRECTORY, + some_existing_dir, False, False) + do_test(questions.Path.DIRECTORY, + some_non_existing_dir, False, True) + + finally: + shutil.rmtree(root) + + def test_normalizing_value(self): + # Expanding Home + home = os.environ.get('HOME') + q = questions.Path('home') + + path = '~/some_path/some_file' + self.assertNotIn(home, path) + self.assertIn(home, q.normalize_value(path)) + + # Normalizing to absolute path + q = questions.Path('abs_path', normalize_to_absolute_path=True) + self.assertEqual('/', q.normalize_value('some/relative/path')[0]) + + def test_default_value_validation(self): + + with self.assertRaises(ValueError): + questions.Path('path', default='~/.toggl_log', + path_type=questions.Path.DIRECTORY) + + questions.Path('path', default='~/.toggl_log')