From 37d5c0add55f483cdca13657775267b8eb14f817 Mon Sep 17 00:00:00 2001 From: Dominic Fitzgerald Date: Mon, 19 Mar 2018 13:40:19 -0500 Subject: [PATCH] Added inquirer directly into Operon until pull request is accepted into the original project --- LICENSE | 17 +++ inquirer/__init__.py | 13 ++ inquirer/errors.py | 26 ++++ inquirer/events.py | 22 ++++ inquirer/prompt.py | 21 +++ inquirer/questions.py | 183 ++++++++++++++++++++++++++ inquirer/render/__init__.py | 10 ++ inquirer/render/console/__init__.py | 190 +++++++++++++++++++++++++++ inquirer/render/console/_checkbox.py | 56 ++++++++ inquirer/render/console/_confirm.py | 29 ++++ inquirer/render/console/_list.py | 62 +++++++++ inquirer/render/console/_password.py | 8 ++ inquirer/render/console/_path.py | 124 +++++++++++++++++ inquirer/render/console/_text.py | 48 +++++++ inquirer/render/console/base.py | 30 +++++ inquirer/themes.py | 115 ++++++++++++++++ setup.py | 12 +- 17 files changed, 964 insertions(+), 2 deletions(-) create mode 100644 inquirer/__init__.py create mode 100644 inquirer/errors.py create mode 100644 inquirer/events.py create mode 100644 inquirer/prompt.py create mode 100644 inquirer/questions.py create mode 100644 inquirer/render/__init__.py create mode 100644 inquirer/render/console/__init__.py create mode 100644 inquirer/render/console/_checkbox.py create mode 100644 inquirer/render/console/_confirm.py create mode 100644 inquirer/render/console/_list.py create mode 100644 inquirer/render/console/_password.py create mode 100644 inquirer/render/console/_path.py create mode 100644 inquirer/render/console/_text.py create mode 100644 inquirer/render/console/base.py create mode 100644 inquirer/themes.py diff --git a/LICENSE b/LICENSE index ff90a70..171ee89 100644 --- a/LICENSE +++ b/LICENSE @@ -672,3 +672,20 @@ may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . + +Inquirer +Original work Copyright 2014 Miguel Ángel García, based on Inquirer.js, by Simon Boudrias +Modified work Copyright 2018 Dominic Fitzgerald + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/inquirer/__init__.py b/inquirer/__init__.py new file mode 100644 index 0000000..5ff1b64 --- /dev/null +++ b/inquirer/__init__.py @@ -0,0 +1,13 @@ +from __future__ import print_function + +__version__ = '2.2.0' + +try: + from .prompt import prompt + from .questions import Text, Password, Confirm, List, Checkbox, \ + load_from_dict, load_from_json, Path + + __all__ = ['prompt', 'Text', 'Password', 'Confirm', 'List', 'Checkbox', + 'load_from_list', 'load_from_dict', 'load_from_json', 'Path'] +except ImportError as e: + print("An error was found, but returning just with the version: %s" % e) diff --git a/inquirer/errors.py b/inquirer/errors.py new file mode 100644 index 0000000..113d362 --- /dev/null +++ b/inquirer/errors.py @@ -0,0 +1,26 @@ +class InquirerError(Exception): + pass + + +class ValidationError(InquirerError): + def __init__(self, value, *args, **kwargs): + super(ValidationError, self).__init__(*args, **kwargs) + self.value = value + + +class UnknownQuestionTypeError(InquirerError): + pass + + +class Aborted(InquirerError): + pass + + +class EndOfInput(InquirerError): + def __init__(self, selection, *args, **kwargs): + super(EndOfInput, self).__init__(*args, **kwargs) + self.selection = selection + + +class ThemeError(AttributeError): + pass diff --git a/inquirer/events.py b/inquirer/events.py new file mode 100644 index 0000000..2e507cf --- /dev/null +++ b/inquirer/events.py @@ -0,0 +1,22 @@ +import readchar + + +class Event(object): + pass + + +class KeyPressed(Event): + def __init__(self, value): + self.value = value + + +class Repaint(Event): + pass + + +class KeyEventGenerator(object): + def __init__(self, key_generator=None): + self._key_gen = key_generator or readchar.readkey + + def next(self): + return KeyPressed(self._key_gen()) diff --git a/inquirer/prompt.py b/inquirer/prompt.py new file mode 100644 index 0000000..bd50a0e --- /dev/null +++ b/inquirer/prompt.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +from .render.console import ConsoleRender +from . import themes + + +def prompt(questions, render=None, answers=None, + theme=themes.Default(), raise_keyboard_interrupt=False): + render = render or ConsoleRender(theme=theme) + answers = answers or {} + + try: + for question in questions: + answers[question.name] = render.render(question, answers) + return answers + except KeyboardInterrupt: + if raise_keyboard_interrupt: + raise + print('') + print('Cancelled by user') + print('') diff --git a/inquirer/questions.py b/inquirer/questions.py new file mode 100644 index 0000000..b7c13fc --- /dev/null +++ b/inquirer/questions.py @@ -0,0 +1,183 @@ +# -*- coding: utf-8 -*- +""" +Module that implements the questions types +""" + +import json + +from . import errors + + +def question_factory(kind, *args, **kwargs): + for clazz in (Text, Password, Confirm, List, Checkbox, Path): + if clazz.kind == kind: + return clazz(*args, **kwargs) + raise errors.UnknownQuestionTypeError() + + +def load_from_dict(question_dict): + """ + Load one question from a dict. + It requires the keys 'name' and 'kind'. + :return: The Question object with associated data. + :return type: Question + """ + return question_factory(**question_dict) + + +def load_from_list(question_list): + """ + Load a list of questions from a list of dicts. + It requires the keys 'name' and 'kind' for each dict. + :return: A list of Question objects with associated data. + :return type: List + """ + return [load_from_dict(q) for q in question_list] + + +def load_from_json(question_json): + """ + Load Questions from a JSON string. + :return: A list of Question objects with associated data if the JSON + contains a list or a Question if the JSON contains a dict. + :return type: List or Dict + """ + data = json.loads(question_json) + if isinstance(data, list): + return load_from_list(data) + if isinstance(data, dict): + return load_from_dict(data) + raise TypeError( + 'Json contained a %s variable when a dict or list was expected', + type(data)) + + +class TaggedValue(object): + def __init__(self, label, value): + self.label = label + self.value = value + + def __str__(self): + return self.label + + def __repr__(self): + return self.value + + def __eq__(self, other): + if isinstance(other, TaggedValue): + return self.value == other.value + return self.value == other + + def __ne__(self, other): + return not self.__eq__(other) + + +class Question(object): + kind = 'base question' + + def __init__(self, + name, + message='', + choices=None, + default=None, + ignore=False, + validate=True, + show_default=False): + self.name = name + self._message = message + self._choices = choices or [] + self._default = default + self._ignore = ignore + self._validate = validate + self.answers = {} + self.show_default = show_default + + @property + def ignore(self): + return bool(self._solve(self._ignore)) + + @property + def message(self): + return self._solve(self._message) + + @property + def default(self): + return self.answers.get(self.name) or self._solve(self._default) + + @property + def choices_generator(self): + for choice in self._solve(self._choices): + yield ( + TaggedValue(*choice) + if isinstance(choice, tuple) and len(choice) == 2 + else choice + ) + + @property + def choices(self): + return list(self.choices_generator) + + def validate(self, current): + try: + if self._solve(self._validate, current): + return + except Exception: + pass + raise errors.ValidationError(current) + + def _solve(self, prop, *args, **kwargs): + if callable(prop): + return prop(self.answers, *args, **kwargs) + if isinstance(prop, str): + return prop.format(**self.answers) + return prop + + +class Text(Question): + kind = 'text' + + +class Password(Question): + kind = 'password' + + +class Confirm(Question): + kind = 'confirm' + + def __init__(self, name, message='', default=False, **kwargs): + super(Confirm, self).__init__(name, message, default=default, **kwargs) + + +class List(Question): + kind = 'list' + + def __init__(self, + name, + message='', + choices=None, + default=None, + ignore=False, + validate=True, + carousel=False): + + super(List, self).__init__( + name, message, choices, + default, ignore, validate + ) + self.carousel = carousel + + +class Checkbox(Question): + kind = 'checkbox' + + +class Path(Question): + kind = 'path' + + def __init__(self, + name, + message='', + midtoken_completion=True, + **kwargs): + super(Path, self).__init__(name, message, **kwargs) + self.midtoken_completion = midtoken_completion diff --git a/inquirer/render/__init__.py b/inquirer/render/__init__.py new file mode 100644 index 0000000..1167b2b --- /dev/null +++ b/inquirer/render/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +from .console import ConsoleRender + + +class Render(object): + def __init__(self, impl=ConsoleRender): + self._impl = impl + + def render(self, question, answers): + return self._impl.render(question, answers) diff --git a/inquirer/render/console/__init__.py b/inquirer/render/console/__init__.py new file mode 100644 index 0000000..d84f1d4 --- /dev/null +++ b/inquirer/render/console/__init__.py @@ -0,0 +1,190 @@ +# -*- coding: utf-8 -*- +from __future__ import print_function + +import sys +from blessings import Terminal + +from inquirer import errors +from inquirer import events +from inquirer import themes +from inquirer.questions import Question +from inquirer.render.console.base import BaseConsoleRender + +from ._text import Text +from ._password import Password +from ._confirm import Confirm +from ._list import List +from ._checkbox import Checkbox +from ._path import Path + + +class ConsoleRender(object): + def __init__(self, event_generator=None, theme=None, *args, **kwargs): + super(ConsoleRender, self).__init__(*args, **kwargs) + self._event_gen = event_generator or events.KeyEventGenerator() + self.terminal = Terminal() + self._previous_error = None + self._position = 0 + self._theme = theme or themes.Default() + self._question_render_matrix = { + 'text': Text, + 'password': Password, + 'confirm': Confirm, + 'list': List, + 'checkbox': Checkbox, + 'path': Path + } + + def render(self, question, answers=None): + question.answers = answers or {} + + if question.ignore: + return question.default + + clazz = self.render_factory(question.kind) + render = clazz(question, + terminal=self.terminal, + theme=self._theme, + show_default=question.show_default) + + self.clear_eos() + + try: + return self._event_loop(render) + finally: + print('') + + def _event_loop(self, render): + try: + while True: + self._relocate() + self._print_status_bar(render) + + self._print_header(render) + self._print_options(render) + + self._process_input(render) + self._force_initial_column() + except errors.EndOfInput as e: + self._go_to_end(render) + return e.selection + + def _print_status_bar(self, render): + if self._previous_error is None: + self.clear_bottombar() + return + + self.render_error(self._previous_error) + self._previous_error = None + + def _print_options(self, render): + for message, symbol, color in render.get_options(): + self.print_line(' {color}{s} {m}{t.normal}', + m=message, color=color, s=symbol) + + def _print_header(self, render): + base = render.get_header() + + header = (base[:self.width - 9] + '...' + if len(base) > self.width - 6 + else base) + default_value = ' ({color}{default}{normal})'.format( + default=render.question.default, + color=self._theme.Question.default_color, + normal=self.terminal.normal) + show_default = render.question.default and render.show_default + header += default_value if show_default else '' + msg_template = "{t.move_up}{t.clear_eol}{tq.brackets_color}["\ + "{tq.mark_color}?{tq.brackets_color}]{t.normal} {msg}" + self.print_str( + '\n%s: %s' % (msg_template, render.get_current_value()), + msg=header, + lf=not render.title_inline, + tq=self._theme.Question) + + def _process_input(self, render): + try: + ev = self._event_gen.next() + if isinstance(ev, events.KeyPressed): + render.process_input(ev.value) + except errors.EndOfInput as e: + try: + render.question.validate(e.selection) + raise + except errors.ValidationError as e: + self._previous_error = ('"{e}" is not a valid {q}.' + .format(e=e.value, + q=render.question.name)) + + def _relocate(self): + print(self._position * self.terminal.move_up, end='') + self._force_initial_column() + self._position = 0 + + def _go_to_end(self, render): + positions = len(list(render.get_options())) - self._position + if positions > 0: + print(self._position * self.terminal.move_down, end='') + self._position = 0 + + def _force_initial_column(self): + self.print_str('\r') + + def render_error(self, message): + if message: + symbol = '>> ' + size = len(symbol) + 1 + length = len(message) + message = message.rstrip() + message = (message + if length + size < self.width + else message[:self.width - (size + 3)] + '...') + + self.render_in_bottombar( + '{t.red}{s}{t.normal}{t.bold}{msg}{t.normal} ' + .format(msg=message, s=symbol, t=self.terminal) + ) + + def render_in_bottombar(self, message): + with self.terminal.location(0, self.height - 2): + self.clear_eos() + self.print_str(message) + + def clear_bottombar(self): + with self.terminal.location(0, self.height - 2): + self.clear_eos() + + def add_question_render(self, question_type_class, question_render_class): + if not issubclass(question_type_class, Question): + raise ValueError('Custom question class must subclass Question') + if not issubclass(question_render_class, BaseConsoleRender): + raise ValueError('Custom question renderer must subclass BaseConsoleRender') + self._question_render_matrix.update({ + question_type_class.kind: question_render_class + }) + + def render_factory(self, question_type): + if question_type not in self._question_render_matrix: + raise errors.UnknownQuestionTypeError() + return self._question_render_matrix.get(question_type) + + def print_line(self, base, lf=True, **kwargs): + self.print_str(base + self.terminal.clear_eol(), lf=lf, **kwargs) + + def print_str(self, base, lf=False, **kwargs): + if lf: + self._position += 1 + + print(base.format(t=self.terminal, **kwargs), end='\n' if lf else '') + sys.stdout.flush() + + def clear_eos(self): + print(self.terminal.clear_eos(), end='') + + @property + def width(self): + return self.terminal.width or 80 + + @property + def height(self): + return self.terminal.width or 24 diff --git a/inquirer/render/console/_checkbox.py b/inquirer/render/console/_checkbox.py new file mode 100644 index 0000000..1ac1a5d --- /dev/null +++ b/inquirer/render/console/_checkbox.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- + +from readchar import key +from .base import BaseConsoleRender +from inquirer import errors + + +class Checkbox(BaseConsoleRender): + def __init__(self, *args, **kwargs): + super(Checkbox, self).__init__(*args, **kwargs) + self.selection = [k for (k, v) in enumerate(self.question.choices) + if v in (self.question.default or [])] + self.current = 0 + + def get_options(self): + for n in range(len(self.question.choices)): + choice = self.question.choices[n] + if n in self.selection: + symbol = self.theme.Checkbox.selected_icon + color = self.theme.Checkbox.selected_color + else: + symbol = self.theme.Checkbox.unselected_icon + color = self.theme.Checkbox.unselected_color + selector = ' ' + if n == self.current: + selector = self.theme.Checkbox.selection_icon + color = self.theme.Checkbox.selection_color + yield choice, selector + ' ' + symbol, color + + def process_input(self, pressed): + if pressed == key.UP: + self.current = max(0, self.current - 1) + return + elif pressed == key.DOWN: + self.current = min(len(self.question.choices) - 1, + self.current + 1) + return + elif pressed == key.SPACE: + if self.current in self.selection: + self.selection.remove(self.current) + else: + self.selection.append(self.current) + elif pressed == key.LEFT: + if self.current in self.selection: + self.selection.remove(self.current) + elif pressed == key.RIGHT: + if self.current not in self.selection: + self.selection.append(self.current) + elif pressed == key.ENTER: + result = [] + for x in self.selection: + value = self.question.choices[x] + result.append(getattr(value, 'value', value)) + raise errors.EndOfInput(result) + elif pressed == key.CTRL_C: + raise KeyboardInterrupt() diff --git a/inquirer/render/console/_confirm.py b/inquirer/render/console/_confirm.py new file mode 100644 index 0000000..f1b0de9 --- /dev/null +++ b/inquirer/render/console/_confirm.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- + +from readchar import key +from inquirer import errors +from .base import BaseConsoleRender + + +class Confirm(BaseConsoleRender): + title_inline = True + + def get_header(self): + confirm = '(Y/n)' if self.question.default else '(y/N)' + return ('{msg} {c}' + .format(msg=self.question.message, + c=confirm)) + + def process_input(self, pressed): + if pressed == key.CTRL_C: + raise KeyboardInterrupt() + + if pressed.lower() == key.ENTER: + raise errors.EndOfInput(self.question.default) + + if pressed in 'yY': + print(pressed) + raise errors.EndOfInput(True) + if pressed in 'nN': + print(pressed) + raise errors.EndOfInput(False) diff --git a/inquirer/render/console/_list.py b/inquirer/render/console/_list.py new file mode 100644 index 0000000..ba46287 --- /dev/null +++ b/inquirer/render/console/_list.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- + +from readchar import key +from .base import BaseConsoleRender +from inquirer import errors + + +class List(BaseConsoleRender): + def __init__(self, *args, **kwargs): + super(List, self).__init__(*args, **kwargs) + self.current = self._current_index() + + def get_options(self): + choices = self.question.choices or [] + + for choice in choices: + selected = choice == choices[self.current] + + if selected: + color = self.theme.List.selection_color + symbol = self.theme.List.selection_cursor + else: + color = self.theme.List.unselected_color + symbol = ' ' + yield choice, symbol, color + + def process_input(self, pressed): + question = self.question + if pressed == key.UP: + if question.carousel and self.current == 0: + self.current = len(question.choices) - 1 + else: + self.current = max(0, self.current - 1) + return + if pressed == key.DOWN: + if question.carousel and self.current == len(question.choices) - 1: + self.current = 0 + else: + self.current = min( + len(self.question.choices) - 1, + self.current + 1 + ) + return + if pressed == key.ENTER: + value = self.question.choices[self.current] + raise errors.EndOfInput(getattr(value, 'value', value)) + + raise errors.EndOfInput(self.question.choices[self.current]) + if pressed == key.CTRL_C: + raise KeyboardInterrupt() + + def _current_index(self): + try: + return self.question.choices.index(self.question.default) + except ValueError: + return 0 + + def get_current_value(self): + try: + return self.question.choices[self.current] + except IndexError: + return '' diff --git a/inquirer/render/console/_password.py b/inquirer/render/console/_password.py new file mode 100644 index 0000000..04757a6 --- /dev/null +++ b/inquirer/render/console/_password.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from ._text import Text + + +class Password(Text): + def get_current_value(self): + return '*' * len(self.current) diff --git a/inquirer/render/console/_path.py b/inquirer/render/console/_path.py new file mode 100644 index 0000000..71e93de --- /dev/null +++ b/inquirer/render/console/_path.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +import os +import sys +import glob +import math +import subprocess + +from ._text import Text +from inquirer import errors + +from readchar import key + + +def get_completion(comp_prefix, reduce_to_common_prefix=True): + """ + Gets a filepath completion based on the current input and the results + returned by the glob module. + + If only one possible option exists, returns that option. If multiple options exists, + returns the longest common prefix among the options, unless reduce_to_common_prefix + is set to False, in which case it returns all options as a list of strings. + + :param comp_prefix: str Current input + :param reduce_to_common_prefix: bool, Whether to reduce multiple options to the longest + common prefix + :return: bool, (str or list) Whether a completion was found, and the completion or list + of completions + """ + options = glob.glob(comp_prefix.replace(r'\ ', ' ') + '*') + if len(options) == 1: + completion = options[0] + if os.path.isdir(completion): + completion += os.path.sep + return completion != comp_prefix, completion + elif len(options) > 1: + if not reduce_to_common_prefix: + return None, options + completion = os.path.commonprefix(options) + return completion != comp_prefix, completion + return False, comp_prefix + + +def show_completion_hint(current, terminal_width, is_unix=True): + """ + Shows all possible completions given the current input. + + On Unix, options will be piped to the more program so that if there are many possible + values the user can scroll through them. On Windows, the whole list is dumped no matter + the size because it's too complicated to make piped output work. + + :param current: str Current input + :param terminal_width: int Width of the terminal, used for layout calculation + :param is_unix: bool Whether this is a Unix terminal + """ + # Get possible completion options, make sure there was more than one available option + _, options = get_completion(current, reduce_to_common_prefix=False) + if not isinstance(options, list): + return + + # Sort options alphabetically, add trailing slashes to directories + sorted_options = sorted([ + os.path.basename(option) + (os.path.sep if os.path.isdir(option) else '') + for option in options + ], key=lambda op: op.lower()) + + # Layout completion options + longest_option = max(*[len(op) for op in sorted_options]) + 2 # 2 is for padding + n_option_cols = int(terminal_width / float(longest_option)) + n_option_rows = int(math.ceil(len(sorted_options) / float(n_option_cols))) + option_rows = [''] * n_option_rows + for i, option in enumerate(sorted_options): + row_i = i % n_option_rows + option_rows[row_i] += option + (' ' * (longest_option - len(option))) + + print() # To move prompt down + if is_unix: + options_matrix = '\n'.join(option_rows) + _echo = subprocess.Popen(['echo', '{}'.format(options_matrix)], stdout=subprocess.PIPE) + subprocess.call(['more'], stdin=_echo.stdout) + _echo.wait() + else: + # On Windows just print it out no matter how big the list is + for row in option_rows: + print(row) + + +class Path(Text): + def __init__(self, *args, **kwargs): + super(Path, self).__init__(*args, **kwargs) + self.show_options = False + + def process_input(self, pressed): + if pressed == '\t': + self.current = os.path.expanduser(self.current) + _completed = False + if sys.platform.startswith('win') or sys.platform.startswith('cygwin'): + # Window style paths + if self.show_options: + # Show options if this is the second consecutive TAB without a completion + show_completion_hint(self.current, self.terminal.width, is_unix=False) + # TODO Support for Windows + else: + # Unix style paths + if self.show_options: + # Show options if this is the second consecutive TAB without a completion + show_completion_hint(self.current, self.terminal.width) + elif self.question.midtoken_completion and self.cursor_offset > 0: + # User completed path somewhere in the middle of the input + _completed, completion = get_completion(self.current[:-self.cursor_offset]) + self.current = ''.join(( + completion, + self.current[-self.cursor_offset:] + )) + elif self.cursor_offset == 0: + # Normal end-of-line completion + _completed, self.current = get_completion(self.current) + + self.show_options = not _completed + elif pressed in {key.CR, key.LF, key.ENTER}: + raise errors.EndOfInput(self.current.strip().replace(' ', r'\ ')) + else: + # Only any key press except TAB, reset showing options + self.show_options = False + super(Path, self).process_input(pressed) diff --git a/inquirer/render/console/_text.py b/inquirer/render/console/_text.py new file mode 100644 index 0000000..9e1f2ed --- /dev/null +++ b/inquirer/render/console/_text.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- + +from readchar import key +from .base import BaseConsoleRender +from inquirer import errors + + +class Text(BaseConsoleRender): + title_inline = True + + def __init__(self, *args, **kwargs): + super(Text, self).__init__(*args, **kwargs) + self.current = self.question.default or '' + self.cursor_offset = 0 + + def get_current_value(self): + return self.current + (self.terminal.move_left * self.cursor_offset) + + 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.current) + + if pressed == key.BACKSPACE: + if self.current and self.cursor_offset != len(self.current): + if self.cursor_offset > 0: + self.current = (self.current[:-self.cursor_offset - 1] + + self.current[-self.cursor_offset:]) + else: + self.current = self.current[:-1] + elif pressed == key.LEFT: + if self.cursor_offset < len(self.current): + self.cursor_offset += 1 + elif pressed == key.RIGHT: + self.cursor_offset = max(self.cursor_offset - 1, 0) + elif len(pressed) != 1: + return + else: + if self.cursor_offset == 0: + self.current += pressed + else: + self.current = ''.join(( + self.current[:-self.cursor_offset], + pressed, + self.current[-self.cursor_offset:] + )) diff --git a/inquirer/render/console/base.py b/inquirer/render/console/base.py new file mode 100644 index 0000000..d1660e7 --- /dev/null +++ b/inquirer/render/console/base.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function + +from blessings import Terminal + + +class BaseConsoleRender(object): + title_inline = False + + def __init__(self, question, theme=None, terminal=None, show_default=False, + *args, **kwargs): + super(BaseConsoleRender, self).__init__(*args, **kwargs) + self.question = question + self.terminal = terminal or Terminal() + self.answers = {} + self.theme = theme + self.show_default = show_default + + def get_header(self): + return self.question.message + + def get_current_value(self): + return '' + + def get_options(self): + return [] + + def read_input(self): + raise NotImplemented('Abstract') diff --git a/inquirer/themes.py b/inquirer/themes.py new file mode 100644 index 0000000..05a6906 --- /dev/null +++ b/inquirer/themes.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +import json + +from collections import namedtuple +from blessings import Terminal + +from .errors import ThemeError + +term = Terminal() + + +def load_theme_from_json(json_theme): + """ + Load a theme from a json. + Expected format: + { + "Question": { + "mark_color": "yellow", + "brackets_color": "normal", + ... + }, + "List": { + "selection_color": "bold_blue", + "selection_cursor": "->" + } + } + + Color values should be string representing valid blessings.Terminal colors. + """ + return load_theme_from_dict(json.loads(json_theme)) + + +def load_theme_from_dict(dict_theme): + """ + Load a theme from a dict. + Expected format: + { + "Question": { + "mark_color": "yellow", + "brackets_color": "normal", + ... + }, + "List": { + "selection_color": "bold_blue", + "selection_cursor": "->" + } + } + + Color values should be string representing valid blessings.Terminal colors. + """ + t = Default() + for question_type, settings in dict_theme.items(): + if question_type not in vars(t): + raise ThemeError('Error while parsing theme. Question type ' + '`{}` not found or not customizable.' + .format(question_type)) + + # calculating fields of namedtuple, hence the filtering + question_fields = list(filter(lambda x: not x.startswith('_'), + vars(getattr(t, question_type)))) + + for field, value in settings.items(): + if field not in question_fields: + raise ThemeError('Error while parsing theme. Field ' + '`{}` invalid for question type `{}`' + .format(field, question_type)) + actual_value = getattr(term, value) or value + setattr(getattr(t, question_type), field, actual_value) + return t + + +class Theme(object): + def __init__(self): + self.Question = namedtuple('question', 'mark_color brackets_color ' + 'default_color') + self.Checkbox = namedtuple('common', 'selection_color selection_icon ' + 'selected_color unselected_color ' + 'selected_icon unselected_icon') + self.List = namedtuple('List', 'selection_color selection_cursor ' + 'unselected_color') + + +class Default(Theme): + def __init__(self): + super(Default, self).__init__() + self.Question.mark_color = term.yellow + self.Question.brackets_color = term.normal + self.Question.default_color = term.normal + self.Checkbox.selection_color = term.blue + self.Checkbox.selection_icon = '>' + self.Checkbox.selected_icon = 'X' + self.Checkbox.selected_color = term.yellow + term.bold + self.Checkbox.unselected_color = term.normal + self.Checkbox.unselected_icon = 'o' + self.List.selection_color = term.blue + self.List.selection_cursor = '>' + self.List.unselected_color = term.normal + + +class GreenPassion(Theme): + + def __init__(self): + super(GreenPassion, self).__init__() + self.Question.mark_color = term.yellow + self.Question.brackets_color = term.bright_green + self.Question.default_color = term.yellow + self.Checkbox.selection_color = term.bold_black_on_bright_green + self.Checkbox.selection_icon = '❯' + self.Checkbox.selected_icon = '◉' + self.Checkbox.selected_color = term.green + self.Checkbox.unselected_color = term.normal + self.Checkbox.unselected_icon = '◯' + self.List.selection_color = term.bold_black_on_bright_green + self.List.selection_cursor = '❯' + self.List.unselected_color = term.normal diff --git a/setup.py b/setup.py index 31d7bf0..ef26514 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,15 @@ import operon +operon_python_dependencies = [ + 'libsubmit', + 'parsl==0.4.0', + 'ipyparallel', + 'networkx==2.0', + 'blessings>=1.6', + 'readchar==0.7' +] + setup( name='Operon', version=operon.__version__, @@ -13,8 +22,7 @@ url='https://github.com/djf604/operon', download_url='https://github.com/djf604/operon/tarball/{}'.format(operon.__version__), packages=find_packages(), - install_requires=['libsubmit', 'parsl==0.4.0', 'ipyparallel', 'networkx==2.0', 'inquirer'], - dependency_links=['git+https://github.com/djf604/python-inquirer'], + install_requires=operon_python_dependencies, entry_points={ 'console_scripts': [ 'operon = operon._util:execute_from_command_line'