diff --git a/.moban.d/CUSTOM_README.rst.jj2 b/.moban.d/CUSTOM_README.rst.jj2 index f21e420..c03b94e 100644 --- a/.moban.d/CUSTOM_README.rst.jj2 +++ b/.moban.d/CUSTOM_README.rst.jj2 @@ -43,4 +43,8 @@ License ================================================================================ NEW BSD License + + +It embeds MIT licensed `cutie <>`_ from Hans Schülein. Please refer to LICENSE +file for more details {% endblock %} \ No newline at end of file diff --git a/LICENSE b/LICENSE index 58bed49..a9deeea 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015-2017 by Onni Software Ltd. and its contributors +Copyright (c) 2015-2020 by Onni Software Ltd. and its contributors All rights reserved. Redistribution and use in source and binary forms of the software as well @@ -27,4 +27,17 @@ PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH -DAMAGE. \ No newline at end of file +DAMAGE. + + +Please note 'cutie' package under under the following license: + +The MIT License (MIT) + +Copyright © 2018 Hans Schülein + +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. \ No newline at end of file diff --git a/README.rst b/README.rst index 54d7c6f..0ca14cc 100644 --- a/README.rst +++ b/README.rst @@ -301,3 +301,7 @@ License ================================================================================ NEW BSD License + + +It embeds MIT licensed `cutie <>`_ from Hans Schülein. Please refer to LICENSE +file for more details diff --git a/requirements.txt b/requirements.txt index 2e21046..602ac2e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,7 @@ ruamel.yaml>=0.15.5;python_version != '3.4' and python_version < '3.7' ruamel.yaml>=0.15.98;python_version == '3.8' Jinja2 moban>=0.6.0 -crayons +colorful +rich +readchar +colorama diff --git a/setup.py b/setup.py index 416bd89..dfece0e 100644 --- a/setup.py +++ b/setup.py @@ -70,7 +70,10 @@ INSTALL_REQUIRES = [ "Jinja2", "moban>=0.6.0", - "crayons", + "colorful", + "rich", + "readchar", + "colorama", ] SETUP_COMMANDS = {} diff --git a/tests/cutie_tests/__init__.py b/tests/cutie_tests/__init__.py new file mode 100644 index 0000000..8b47008 --- /dev/null +++ b/tests/cutie_tests/__init__.py @@ -0,0 +1,65 @@ +from yehua.thirdparty import cutie + +import readchar + + +def PrintCall(states): + def func(msg=None, state="selectable"): + if msg: + return ((states[state] + msg,),) + else: + return ((states[state],),) + + return func + + +def yield_input(*data, raise_on_empty=False): + """ + Closure that returns predefined data. + + If the data is exhausted raise a MockException or reraise the IndexError + """ + data = list(data) + + def func(*a, **kw): + try: + return data.pop(0) + except IndexError as e: + if raise_on_empty: + raise MockException() + else: + raise e + + return func + + +class InputContext: + """ + Context manager to simulate keyboard input returned by `readchar.readkey`, + by replacing it in `cutie` with `yield_input` + + When the supplied keystrokes are exhausted a `MockException` will be + raised. This can be used to terminate the execution at any desired point, + rather than relying on internal control mechanisms. + + Usage: + with InputContext(" ", "\r"): + cutie.select(["foo", "bar"]) + This will issue a space and enter keypress, selecting the first item and + confirming. + """ + + def __init__(self, *data, raise_on_empty=True): + cutie.readchar.readkey = yield_input( + *data, raise_on_empty=raise_on_empty + ) + + def __enter__(self): + pass + + def __exit__(self, *a): + cutie.readchar.readkey = readchar.readkey + + +class MockException(Exception): + pass diff --git a/tests/cutie_tests/test_get_number.py b/tests/cutie_tests/test_get_number.py new file mode 100644 index 0000000..1792fd6 --- /dev/null +++ b/tests/cutie_tests/test_get_number.py @@ -0,0 +1,112 @@ +import unittest +from unittest import mock + +from yehua.thirdparty import cutie + +from . import MockException + + +class TestCutieGetNumber(unittest.TestCase): + @mock.patch("yehua.thirdparty.cutie.print", side_effect=MockException) + def test_invalid_number(self, mock_print): + with mock.patch("yehua.thirdparty.cutie.input", return_value="foo"): + with self.assertRaises(MockException): + cutie.get_number("bar") + mock_print.assert_called_once_with( + "Not a valid number.\033[K\033[1A\r\033[K", end="" + ) + + @mock.patch("yehua.thirdparty.cutie.print", side_effect=MockException) + def test_not_allow_float(self, mock_print): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1.2"): + with self.assertRaises(MockException): + cutie.get_number("foo", allow_float=False) + mock_print.assert_called_once_with( + "Has to be an integer.\033[K\033[1A\r\033[K", end="" + ) + + def test_allow_float_returns_float(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1.2"): + val = cutie.get_number("foo") + self.assertIsInstance(val, float) + self.assertEqual(val, 1.2) + + def test_not_allow_float_returns_int(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1"): + val = cutie.get_number("foo", allow_float=False) + self.assertIsInstance(val, int) + self.assertEqual(val, 1) + + @mock.patch("yehua.thirdparty.cutie.print", side_effect=MockException) + def test_min_value_float_too_low(self, mock_print): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1.2"): + with self.assertRaises(MockException): + cutie.get_number("foo", min_value=1.3) + mock_print.assert_called_once_with( + "Has to be at least 1.3.\033[K\033[1A\r\033[K", end="" + ) + + def test_min_value_float_equal(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1.2"): + self.assertEqual(cutie.get_number("foo", min_value=1.2), 1.2) + + def test_min_value_float_greater(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1.3"): + self.assertEqual(cutie.get_number("foo", min_value=1.2), 1.3) + + @mock.patch("yehua.thirdparty.cutie.print", side_effect=MockException) + def test_min_value_int_too_low(self, mock_print): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1"): + with self.assertRaises(MockException): + cutie.get_number("foo", min_value=2) + mock_print.assert_called_once_with( + "Has to be at least 2.\033[K\033[1A\r\033[K", end="" + ) + + def test_min_value_int_equal(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1"): + self.assertEqual(cutie.get_number("foo", min_value=1), 1) + + def test_min_value_int_greater(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="2"): + self.assertEqual(cutie.get_number("foo", min_value=1), 2) + + @mock.patch("yehua.thirdparty.cutie.print", side_effect=MockException) + def test_max_value_float_too_high(self, mock_print): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1.2"): + with self.assertRaises(MockException): + cutie.get_number("foo", max_value=1.1) + mock_print.assert_called_once_with( + "Has to be at most 1.1.\033[1A\r\033[K", end="" + ) + + def test_max_value_float_equal(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1.1"): + self.assertEqual(cutie.get_number("foo", max_value=1.1), 1.1) + + def test_max_value_float_smaller(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1.1"): + self.assertEqual(cutie.get_number("foo", max_value=1.2), 1.1) + + @mock.patch("yehua.thirdparty.cutie.print", side_effect=MockException) + def test_max_value_int_too_high(self, mock_print): + with mock.patch("yehua.thirdparty.cutie.input", return_value="2"): + with self.assertRaises(MockException): + cutie.get_number("foo", max_value=1) + mock_print.assert_called_once_with( + "Has to be at most 1.\033[1A\r\033[K", end="" + ) + + def test_max_value_int_equal(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1"): + self.assertEqual(cutie.get_number("foo", max_value=1), 1) + + def test_max_value_int_smaller(self): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1"): + self.assertEqual(cutie.get_number("foo", max_value=2), 1) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_finalize(self, mock_print): + with mock.patch("yehua.thirdparty.cutie.input", return_value="1"): + cutie.get_number("foo") + mock_print.assert_called_once_with("\033[K", end="") diff --git a/tests/cutie_tests/test_prompt_yes_or_no.py b/tests/cutie_tests/test_prompt_yes_or_no.py new file mode 100644 index 0000000..262307f --- /dev/null +++ b/tests/cutie_tests/test_prompt_yes_or_no.py @@ -0,0 +1,256 @@ +import unittest +from unittest import mock + +import readchar +from . import PrintCall, InputContext, cutie + +print_call = PrintCall( + {"selected": "\x1b[K\x1b[31m>\x1b[0m ", "selectable": "\x1b[K "} +) + + +class TestPromtYesOrNo(unittest.TestCase): + + default_yes_print_calls = [ + (tuple(),), + (("\x1b[K\x1b[31m>\x1b[0m Yes",),), + (("\x1b[K No",),), + (("\x1b[3A\r\x1b[Kfoo (Y/N) Yes",), {"end": "", "flush": True}), + (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), + ] + + default_no_print_calls = [ + (tuple(),), + (("\x1b[K Yes",),), + (("\x1b[K\x1b[31m>\x1b[0m No",),), + (("\x1b[3A\r\x1b[Kfoo (Y/N) No",), {"end": "", "flush": True}), + (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), + ] + + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_message(self, mock_print): + expected_calls = [ + (tuple(),), + (("\x1b[K Yes",),), + (("\x1b[K\x1b[31m>\x1b[0m No",),), + (("\x1b[3A\r\x1b[Kfoo (Y/N) ",), {"end": "", "flush": True}), + (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), + ] + with InputContext("\r"): + cutie.prompt_yes_or_no("foo") + self.assertEqual(mock_print.call_args_list, expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_message_custom_prefixes(self, mock_print): + expected_calls = [(("\x1b[K+Yes",),), (("\x1b[K*No",),)] + with InputContext("\r"): + cutie.prompt_yes_or_no( + "foo", selected_prefix="*", deselected_prefix="+" + ) + self.assertEqual(mock_print.call_args_list[1:3], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_message_custom_yes_no_text(self, mock_print): + expected_calls = [ + (("\x1b[K bar",),), + (("\x1b[K\x1b[31m>\x1b[0m baz",),), + ] + with InputContext("\r"): + cutie.prompt_yes_or_no("foo", yes_text="bar", no_text="baz") + self.assertEqual(mock_print.call_args_list[1:3], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_message_default_is_yes(self, mock_print): + expected_calls = [ + (("\x1b[K\x1b[31m>\x1b[0m Yes",),), + (("\x1b[K No",),), + ] + with InputContext("\r"): + cutie.prompt_yes_or_no("foo", default_is_yes=True) + self.assertEqual(mock_print.call_args_list[1:3], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_up(self, mock_print): + with InputContext(readchar.key.UP, "\r"): + self.assertTrue(cutie.prompt_yes_or_no("foo")) + self.assertEqual( + mock_print.call_args_list[-5:], self.default_yes_print_calls + ) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_up_over_boundary(self, mock_print): + with InputContext(readchar.key.UP, readchar.key.UP, "\r"): + self.assertFalse(cutie.prompt_yes_or_no("foo")) + self.assertEqual( + mock_print.call_args_list[-5:], self.default_no_print_calls + ) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_down(self, mock_print): + with InputContext(readchar.key.DOWN, "\r"): + self.assertFalse( + cutie.prompt_yes_or_no("foo", default_is_yes=True) + ) + self.assertEqual( + mock_print.call_args_list[-5:], self.default_no_print_calls + ) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_down_over_boundary(self, mock_print): + with InputContext(readchar.key.DOWN, "\r"): + self.assertTrue(cutie.prompt_yes_or_no("foo")) + self.assertEqual( + mock_print.call_args_list[-5:], self.default_yes_print_calls + ) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_backspace_delete_char(self, mock_print): + expected_calls = [ + (tuple(),), + print_call("Yes", "selected"), + print_call("No"), + (("\x1b[3A\r\x1b[Kfoo (Y/N) Ye",), {"end": "", "flush": True}), + (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), + ] + with InputContext(readchar.key.UP, readchar.key.BACKSPACE, "\r"): + cutie.prompt_yes_or_no("foo") + self.assertEqual(mock_print.call_args_list[-5:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_ctrl_c_abort(self, *m): + with InputContext(readchar.key.CTRL_C): + with self.assertRaises(KeyboardInterrupt): + cutie.prompt_yes_or_no("") + + @mock.patch("yehua.thirdparty.cutie.print") + def test_ctrl_c_abort_with_input(self, *m): + with InputContext(readchar.key.UP, readchar.key.CTRL_D): + with self.assertRaises(KeyboardInterrupt): + cutie.prompt_yes_or_no("") + + @mock.patch("yehua.thirdparty.cutie.print") + def test_ctrl_d_abort(self, *m): + with InputContext(readchar.key.CTRL_D): + with self.assertRaises(KeyboardInterrupt): + cutie.prompt_yes_or_no("") + + @mock.patch("yehua.thirdparty.cutie.print") + def test_ctrl_d_abort_with_input(self, *m): + with InputContext(readchar.key.UP, readchar.key.CTRL_D): + with self.assertRaises(KeyboardInterrupt): + cutie.prompt_yes_or_no("") + + @mock.patch("yehua.thirdparty.cutie.print") + def test_enter_confirm_default(self, *m): + with InputContext(readchar.key.ENTER): + self.assertFalse(cutie.prompt_yes_or_no("")) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_enter_confirm_selection(self, *m): + with InputContext(readchar.key.UP, readchar.key.ENTER): + self.assertTrue(cutie.prompt_yes_or_no("")) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_tab_select(self, mock_print): + expected_calls = [ + (tuple(),), + print_call("Yes"), + print_call("No", "selected"), + (("\x1b[3A\r\x1b[Kfoo (Y/N) No",), {"end": "", "flush": True}), + (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), + ] + with InputContext("\t", "\r"): + cutie.prompt_yes_or_no("foo") + self.assertEqual(mock_print.call_args_list[-5:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_write_keypress_to_terminal(self, mock_print): + expected_calls = [ + (tuple(),), + print_call("Yes"), + print_call("No", "selected"), + (("\x1b[3A\r\x1b[Kfoo (Y/N) ",), {"end": "", "flush": True}), + (tuple(),), + print_call("Yes"), + print_call("No"), + (("\x1b[3A\r\x1b[Kfoo (Y/N) f",), {"end": "", "flush": True}), + (tuple(),), + print_call("Yes"), + print_call("No"), + (("\x1b[3A\r\x1b[Kfoo (Y/N) fo",), {"end": "", "flush": True}), + (tuple(),), + print_call("Yes"), + print_call("No"), + (("\x1b[3A\r\x1b[Kfoo (Y/N) foo",), {"end": "", "flush": True}), + ] + with InputContext("f", "o", "o", readchar.key.CTRL_C): + with self.assertRaises(KeyboardInterrupt): + cutie.prompt_yes_or_no("foo") + self.assertEqual(mock_print.call_args_list, expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_write_keypress_to_terminal_resume_selection(self, mock_print): + expected_calls = [ + (tuple(),), + print_call("Yes", "selected"), + print_call("No"), + (("\x1b[3A\r\x1b[Kfoo (Y/N) Yes",), {"end": "", "flush": True}), + (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), + ] + with InputContext("f", readchar.key.DOWN, "\r"): + self.assertTrue(cutie.prompt_yes_or_no("foo")) + self.assertEqual(mock_print.call_args_list[-5:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_evaluate_written_input_yes_ignorecase(self, mock_print): + expected_calls = [ + (tuple(),), + print_call("Yes", "selected"), + print_call("No"), + (("\x1b[3A\r\x1b[Kfoo (Y/N) yes",), {"end": "", "flush": True}), + (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), + ] + with InputContext("y", "e", "s", "\r"): + self.assertTrue(cutie.prompt_yes_or_no("foo")) + self.assertEqual(mock_print.call_args_list[-5:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_evaluate_written_input_yes_case_sensitive(self, mock_print): + expected_calls = ( + ("\x1b[3A\r\x1b[Kfoo (Y/N) yes",), + {"end": "", "flush": True}, + ) + + with InputContext("y", "e", "s", readchar.key.CTRL_C): + res = None + with self.assertRaises(KeyboardInterrupt): + res = cutie.prompt_yes_or_no("foo", has_to_match_case=True) + self.assertIsNone(res) + self.assertEqual(mock_print.call_args_list[-1], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_evaluate_written_input_no_ignorecase(self, mock_print): + expected_calls = [ + (tuple(),), + print_call("Yes"), + print_call("No", "selected"), + (("\x1b[3A\r\x1b[Kfoo (Y/N) no",), {"end": "", "flush": True}), + (("\x1b[K\n\x1b[K\n\x1b[K\n\x1b[3A",),), + ] + with InputContext("n", "o", "\r"): + self.assertFalse(cutie.prompt_yes_or_no("foo")) + self.assertEqual(mock_print.call_args_list[-5:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_evaluate_written_input_no_case_sensitive(self, mock_print): + expected_calls = ( + ("\x1b[3A\r\x1b[Kfoo (Y/N) no",), + {"end": "", "flush": True}, + ) + + with InputContext("n", "o", readchar.key.CTRL_C): + res = None + with self.assertRaises(KeyboardInterrupt): + res = cutie.prompt_yes_or_no("foo", has_to_match_case=True) + self.assertIsNone(res) + self.assertEqual(mock_print.call_args_list[-1], expected_calls) diff --git a/tests/cutie_tests/test_secure_input.py b/tests/cutie_tests/test_secure_input.py new file mode 100644 index 0000000..0ceeb16 --- /dev/null +++ b/tests/cutie_tests/test_secure_input.py @@ -0,0 +1,13 @@ +import unittest +from unittest import mock + +from yehua.thirdparty import cutie + + +class TestSecureInput(unittest.TestCase): + def test_secure_input(self): + with mock.patch( + "yehua.thirdparty.cutie.getpass.getpass", return_value="foo" + ) as mock_getpass: + self.assertEqual(cutie.secure_input("foo"), "foo") + mock_getpass.assert_called_once_with("foo ") diff --git a/tests/cutie_tests/test_select.py b/tests/cutie_tests/test_select.py new file mode 100644 index 0000000..bc2fef9 --- /dev/null +++ b/tests/cutie_tests/test_select.py @@ -0,0 +1,182 @@ +import string +import unittest +from unittest import mock + +import readchar +from . import PrintCall, InputContext, MockException, cutie + +print_call = PrintCall( + { + "selectable": "\x1b[K\x1b[1m[ ]\x1b[0m ", + "selected": "\x1b[K\x1b[1m[\x1b[32;1mx\x1b[0;1m]\x1b[0m ", + "caption": "\x1b[K", + } +) + + +class TestSelect(unittest.TestCase): + @mock.patch("yehua.thirdparty.cutie.print", side_effect=MockException) + def test_print_list_newlines(self, mock_print): + args_list = ["foo", "bar"] + with self.assertRaises(MockException): + cutie.select(args_list) + mock_print.assert_called_once_with("\n" * (len(args_list) - 1)) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_move_to_first_item(self, mock_print, *m): + args_list = ["foo", "bar"] + with self.assertRaises(MockException): + cutie.select(args_list) + self.assertEqual( + mock_print.call_args_list[1], ((f"\033[{len(args_list) + 1}A",),) + ) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_options(self, mock_print, *m): + args_list = ["foo", "bar"] + expected_calls = [print_call("foo", "selected"), print_call("bar")] + with self.assertRaises(MockException): + cutie.select(args_list) + self.assertEqual(mock_print.call_args_list[2:], expected_calls) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_options_selected_index_set(self, mock_print, *m): + args_list = ["foo", "bar"] + expected_calls = [print_call("foo"), print_call("bar", "selected")] + with self.assertRaises(MockException): + cutie.select(args_list, selected_index=1) + self.assertEqual(mock_print.call_args_list[2:], expected_calls) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_non_selectable(self, mock_print, *m): + args_list = ["foo", "bar"] + expected_calls = [ + print_call("foo", "selected"), + print_call("bar", "caption"), + ] + with self.assertRaises(MockException): + cutie.select(args_list, caption_indices=[1]) + self.assertEqual(mock_print.call_args_list[2:], expected_calls) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_options_custom_prefixes(self, mock_print, *m): + args_list = ["foo", "bar", "baz"] + expected_calls = [ + (("\x1b[K*foo",),), + (("\x1b[K+bar",),), + (("\x1b[K$baz",),), + ] + with self.assertRaises(MockException): + cutie.select( + args_list, + caption_indices=[2], + selected_prefix="*", + deselected_prefix="+", + caption_prefix="$", + ) + self.assertEqual(mock_print.call_args_list[2:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_ignore_unrecognized_key(self, mock_print): + exclude = [ + "__builtins__", + "__cached__", + "__doc__", + "__file__", + "__loader__", + "__name__", + "__package__", + "__spec__", + "UP", + "DOWN", + "ENTER", + "CTRL_C", + "CTRL_D", + ] + all_keys = [ + getattr(readchar.key, k) + for k in dir(readchar.key) + if k not in exclude + ] + all_keys.extend(string.printable) + expected_calls = [ + (("",),), + (("\x1b[2A",),), + (("\x1b[K\x1b[1m[\x1b[32;1mx\x1b[0;1m]\x1b[0m foo",),), + ] + + for key in all_keys: + with InputContext(readchar.key.DOWN, key, readchar.key.ENTER): + selindex = cutie.select(["foo"]) + self.assertEqual(selindex, 0) + self.assertEqual(mock_print.call_args_list[:3], expected_calls) + mock_print.reset_mock() + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_up(self, *m): + with InputContext(readchar.key.UP, "\r"): + args_list = ["foo", "bar"] + selindex = cutie.select(args_list, selected_index=1) + self.assertEqual(selindex, 0) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_up_skip_caption(self, *m): + with InputContext(readchar.key.UP, "\r"): + args_list = ["foo", "bar", "baz"] + selindex = cutie.select( + args_list, selected_index=2, caption_indices=[1] + ) + self.assertEqual(selindex, 0) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_down(self, *m): + with InputContext(readchar.key.DOWN, "\r"): + args_list = ["foo", "bar"] + selindex = cutie.select(args_list) + self.assertEqual(selindex, 1) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_down_skip_caption(self, *m): + with InputContext(readchar.key.DOWN, "\r"): + args_list = ["foo", "bar", "baz"] + selindex = cutie.select(args_list, caption_indices=[1]) + self.assertEqual(selindex, 2) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_keyboard_interrupt_ctrl_c_no_input(self, *m): + with InputContext(readchar.key.CTRL_C): + with self.assertRaises(KeyboardInterrupt): + cutie.select(["foo"]) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_keyboard_interrupt_ctrl_c_selected(self, *m): + with InputContext(readchar.key.DOWN, readchar.key.CTRL_C): + with self.assertRaises(KeyboardInterrupt): + cutie.select(["foo"], selected_index=0) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_keyboard_interrupt_ctrl_d_no_input(self, *m): + with InputContext(readchar.key.CTRL_D): + with self.assertRaises(KeyboardInterrupt): + cutie.select(["foo"]) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_keyboard_interrupt_ctrl_d_selected(self, *m): + with InputContext(readchar.key.DOWN, readchar.key.CTRL_D): + with self.assertRaises(KeyboardInterrupt): + cutie.select(["foo"], selected_index=0) diff --git a/tests/cutie_tests/test_select_multiple.py b/tests/cutie_tests/test_select_multiple.py new file mode 100644 index 0000000..9d0d98a --- /dev/null +++ b/tests/cutie_tests/test_select_multiple.py @@ -0,0 +1,355 @@ +import unittest +from unittest import mock + +import readchar +from . import PrintCall, InputContext, MockException, cutie + +print_call = PrintCall( + { + "selectable": "\x1b[K\x1b[1m( )\x1b[0m ", + "selected": "\x1b[K\x1b[1m(\x1b[32mx\x1b[0;1m)\x1b[0m ", + "caption": "\x1b[K", + "active": "\x1b[K\x1b[32;1m{ }\x1b[0m ", + "active-selected": "\x1b[K\x1b[32;1m{x}\x1b[0m ", + "confirm": "\x1b[1m(( confirm ))\x1b[0m \x1b[K", + "confirm-active": "\x1b[1;32m{{ confirm }}\x1b[0m \x1b[K", + } +) + + +PRINT_CALL_END = (("\x1b[1A\x1b[K",), {"end": "", "flush": True}) + + +class TestSelectMultiplePrint(unittest.TestCase): + @mock.patch("yehua.thirdparty.cutie.print", side_effect=MockException) + def test_list_newlines(self, mock_print): + args_list = ["foo", "bar"] + with self.assertRaises(MockException): + cutie.select_multiple(args_list) + mock_print.assert_called_once_with("\n" * (len(args_list) - 1)) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_to_first_item(self, mock_print, *m): + args_list = ["foo", "bar"] + with self.assertRaises(MockException): + cutie.select_multiple(args_list) + self.assertEqual( + mock_print.call_args_list[1], ((f"\033[{len(args_list) + 2}A",),) + ) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_options(self, mock_print, *m): + args_list = ["foo", "bar"] + with self.assertRaises(MockException): + cutie.select_multiple(args_list) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_options_caption_indices(self, mock_print, *m): + args_list = ["foo", "bar"] + expected_calls = [ + print_call("foo", "caption"), + print_call("bar"), + print_call(state="caption"), + ] + with self.assertRaises(MockException): + cutie.select_multiple( + args_list, hide_confirm=True, caption_indices=[0] + ) + self.assertEqual(mock_print.call_args_list[-3:], expected_calls) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_options_selected(self, mock_print, *m): + args_list = ["foo", "bar"] + expected_calls = [ + print_call("foo"), + print_call("bar", "active"), + print_call(state="caption"), + ] + with self.assertRaises(MockException): + cutie.select_multiple(args_list, hide_confirm=True, cursor_index=1) + self.assertEqual(mock_print.call_args_list[-3:], expected_calls) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_options_selected_and_ticked(self, mock_print, *m): + args_list = ["foo", "bar"] + expected_calls = [ + print_call("foo", "active-selected"), + print_call("bar"), + print_call(state="caption"), + ] + with self.assertRaises(MockException): + cutie.select_multiple( + args_list, hide_confirm=True, ticked_indices=[0] + ) + self.assertEqual(mock_print.call_args_list[-3:], expected_calls) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_options_deselected_unticked(self, mock_print, *m): + args_list = ["foo", "bar"] + expected_calls = [ + print_call("foo"), + print_call("bar"), + print_call(state="caption"), + ] + with self.assertRaises(MockException): + cutie.select_multiple(args_list, hide_confirm=True, cursor_index=2) + self.assertEqual(mock_print.call_args_list[-3:], expected_calls) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_deselected_confirm(self, mock_print, *m): + expected_call = print_call(state="confirm") + with self.assertRaises(MockException): + cutie.select_multiple([], cursor_index=1) + self.assertEqual(mock_print.call_args_list[-1], expected_call) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_selected_confirm(self, mock_print, *m): + expected_call = print_call(state="confirm-active") + with self.assertRaises(MockException): + cutie.select_multiple([]) + self.assertEqual(mock_print.call_args_list[-1], expected_call) + + @mock.patch( + "yehua.thirdparty.cutie.readchar.readkey", side_effect=MockException + ) + @mock.patch("yehua.thirdparty.cutie.print") + def test_print_hide_confirm(self, mock_print, *m): + expected_calls = [ + print_call("foo", "active"), + print_call("", "caption"), + ] + with self.assertRaises(MockException): + cutie.select_multiple(["foo"], hide_confirm=True) + self.assertEqual(mock_print.call_args_list[2:], expected_calls) + + +class TestSelectMultipleMoveAndSelect(unittest.TestCase): + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_up(self, mock_print): + call_args = ["foo", "bar"] + expected_calls = [ + print_call("foo", "active"), + print_call("bar"), + print_call("", "caption"), + PRINT_CALL_END, + ] + with InputContext(readchar.key.UP, "\r"): + cutie.select_multiple(call_args, cursor_index=1, hide_confirm=True) + self.assertEqual(mock_print.call_args_list[-4:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_up_skip_caption(self, mock_print): + call_args = ["foo", "bar", "baz"] + expected_calls = [ + print_call("foo", "active"), + print_call("bar", "caption"), + print_call("baz"), + print_call("", "caption"), + PRINT_CALL_END, + ] + with InputContext(readchar.key.UP, "\r"): + cutie.select_multiple( + call_args, + cursor_index=2, + hide_confirm=True, + caption_indices=[1], + ) + self.assertEqual(mock_print.call_args_list[-5:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_down(self, mock_print): + call_args = ["foo", "bar"] + expected_calls = [ + print_call("foo"), + print_call("bar", "active"), + print_call("", "caption"), + PRINT_CALL_END, + ] + with InputContext(readchar.key.DOWN, "\r"): + cutie.select_multiple(call_args, hide_confirm=True) + self.assertEqual(mock_print.call_args_list[-4:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_move_down_skip_caption(self, mock_print): + call_args = ["foo", "bar", "baz"] + expected_calls = [ + print_call("foo"), + print_call("bar", "caption"), + print_call("baz", "active"), + print_call("", "caption"), + PRINT_CALL_END, + ] + with InputContext(readchar.key.DOWN, "\r"): + cutie.select_multiple( + call_args, hide_confirm=True, caption_indices=[1] + ) + self.assertEqual(mock_print.call_args_list[-5:], expected_calls) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_select(self, mock_print): + call_args = ["foo", "bar"] + expected_calls = [ + print_call("foo", "selected"), + print_call("bar", "selected"), + print_call(state="confirm-active"), + PRINT_CALL_END, + ] + with InputContext( + " ", readchar.key.DOWN, " ", readchar.key.DOWN, readchar.key.ENTER + ): + selected_indices = cutie.select_multiple(call_args) + self.assertEqual(mock_print.call_args_list[-4:], expected_calls) + self.assertEqual(selected_indices, [0, 1]) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_select_min_too_few(self, mock_print): + call_args = ["foo"] + expected_call = ( + ( + "\x1b[1;32m{{ confirm }}\x1b[0m Must " + + "select at least 1 options\x1b[K", + ), + ) + with InputContext(readchar.key.DOWN, "\r"): + with self.assertRaises(MockException): + cutie.select_multiple(call_args, minimal_count=1) + self.assertEqual(mock_print.call_args_list[-1], expected_call) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_select_min_sufficient(self, mock_print): + call_args = ["foo"] + expected_calls = [ + print_call("foo", "selected"), + print_call(state="confirm-active"), + PRINT_CALL_END, + ] + with InputContext(" ", readchar.key.DOWN, readchar.key.ENTER): + selected_indices = cutie.select_multiple( + call_args, minimal_count=1 + ) + self.assertEqual(mock_print.call_args_list[-3:], expected_calls) + self.assertEqual(selected_indices, [0]) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_deny_deselect_on_min_too_few(self, mock_print): + """Trying to deselect here shouldn't be possible""" + call_args = ["foo"] + expected_calls = [ + print_call("foo", "selected"), + print_call(state="confirm-active"), + PRINT_CALL_END, + ] + with InputContext(" ", readchar.key.DOWN, readchar.key.ENTER): + selected_indices = cutie.select_multiple( + call_args, minimal_count=1, ticked_indices=[0] + ) + self.assertEqual(mock_print.call_args_list[-3:], expected_calls) + self.assertEqual(selected_indices, [0]) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_deselect_on_min_sufficient(self, mock_print): + call_args = ["foo", "bar"] + expected_calls = [ + print_call("foo"), + print_call("bar", "selected"), + print_call(state="confirm-active"), + PRINT_CALL_END, + ] + with InputContext( + " ", readchar.key.DOWN, readchar.key.DOWN, readchar.key.ENTER + ): + selected_indices = cutie.select_multiple( + call_args, minimal_count=1, ticked_indices=[0, 1] + ) + self.assertEqual(mock_print.call_args_list[-4:], expected_calls) + self.assertEqual(selected_indices, [1]) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_select_max_try_select_too_many(self, mock_print): + """Trying to select additional options shouldn't be possible""" + call_args = ["foo", "bar"] + expected_calls = [ + print_call("foo", "selected"), + print_call("bar"), + print_call(state="confirm-active"), + PRINT_CALL_END, + ] + with InputContext(" ", readchar.key.DOWN, readchar.key.ENTER): + selected_indices = cutie.select_multiple( + call_args, maximal_count=1, ticked_indices=[0], cursor_index=1 + ) + self.assertEqual(mock_print.call_args_list[-4:], expected_calls) + self.assertEqual(selected_indices, [0]) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_select_max_okay(self, mock_print): + call_args = ["foo"] + expected_calls = [ + print_call("foo", "selected"), + print_call(state="confirm-active"), + PRINT_CALL_END, + ] + with InputContext(" ", readchar.key.DOWN, readchar.key.ENTER): + selected_indices = cutie.select_multiple( + call_args, maximal_count=1 + ) + self.assertEqual(mock_print.call_args_list[-3:], expected_calls) + self.assertEqual(selected_indices, [0]) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_select_min_too_few_hide_confirm(self, mock_print): + """ + This should prompt the user with an error message + """ + call_args = ["foo"] + expected_call = (("Must select at least 1 options\x1b[K",),) + with InputContext(readchar.key.DOWN, "\r"): + with self.assertRaises(MockException): + cutie.select_multiple( + call_args, minimal_count=1, hide_confirm=True + ) + self.assertEqual(mock_print.call_args_list[-1], expected_call) + + @mock.patch("yehua.thirdparty.cutie.print") + def test_select_max_try_select_too_many_hide_confirm(self, mock_print): + """Trying to select additional options shouldn't be possible""" + call_args = ["foo", "bar"] + expected_calls = [ + print_call("foo", "selected"), + print_call("bar", "active"), + print_call("", "caption"), + PRINT_CALL_END, + ] + with InputContext(readchar.key.DOWN, " ", "\r"): + selected_indices = cutie.select_multiple( + call_args, + maximal_count=1, + ticked_indices=[0], + hide_confirm=True, + ) + self.assertEqual(mock_print.call_args_list[-4:], expected_calls) + self.assertEqual(selected_indices, [0]) diff --git a/tests/test_project.py b/tests/test_project.py index 797c58d..10a2cfb 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -156,7 +156,9 @@ def test_template(): assert answers["bar"] == "hello" -def test_get_complex_user_inputs(): +@patch("yehua.utils.cutie.select") +@patch("yehua.utils.yehua_input") +def test_get_complex_user_inputs(fake_input, fake_select): from yehua.utils import get_user_inputs simple_questions = [ @@ -171,8 +173,8 @@ def test_get_complex_user_inputs(): } ] - with patch("yehua.utils.yehua_input") as yehua_input: - yehua_input.side_effect = ["2", "hello"] - answers = get_user_inputs(simple_questions) - eq_(answers["hello"], "option 2") - eq_(answers["option 2"], "hello") + fake_select.return_value = 2 + fake_input.return_value = "hello" + answers = get_user_inputs(simple_questions) + eq_(answers["hello"], "option 2") + eq_(answers["option 2"], "hello") diff --git a/yehua.yaml b/yehua.yaml index 5603c49..01546e9 100644 --- a/yehua.yaml +++ b/yehua.yaml @@ -21,7 +21,10 @@ dependencies: - ruamel.yaml>=0.15.98;python_version == '3.8' - Jinja2 - moban>=0.6.0 - - crayons + - colorful + - rich + - readchar + - colorama extra_dependencies: - pypi-mobans: - pypi-mobans-pkg==0.0.12 diff --git a/yehua/cookiecutter.py b/yehua/cookiecutter.py index ddc5775..0ffe6ad 100644 --- a/yehua/cookiecutter.py +++ b/yehua/cookiecutter.py @@ -61,7 +61,7 @@ def _template_yehua_file(self): def _ask_questions(self): first_stage = utils.load_yaml(self.project_content) - print(first_stage["introduction"]) + utils.color_print(first_stage["introduction"]) self.answers = get_user_inputs(first_stage["questions"]) my_dict = {"cookiecutter": deepcopy(self.answers)} diff --git a/yehua/cookiecutter_to_yehua.py b/yehua/cookiecutter_to_yehua.py index ecc9e91..18eff46 100644 --- a/yehua/cookiecutter_to_yehua.py +++ b/yehua/cookiecutter_to_yehua.py @@ -7,7 +7,7 @@ import fs INTRODUCTION = """ -Yehua will walk you through cookiecutter templating wizard. +[info]Yehua /'jɛhwa/[/info] will walk you through cookiecutter template wizard Press ^C to quit at any time. """ diff --git a/yehua/project.py b/yehua/project.py index 50e54a8..4a182a1 100644 --- a/yehua/project.py +++ b/yehua/project.py @@ -74,7 +74,7 @@ def end(self): def _ask_questions(self): content = read_unicode(self.project_file) first_stage = utils.load_yaml(content) - print(first_stage["introduction"]) + utils.color_print(first_stage["introduction"]) base_path = fs.path.dirname(self.project_file) with fs.open_fs(base_path) as the_fs: self.template_dir = os.path.join( diff --git a/yehua/theme.py b/yehua/theme.py new file mode 100644 index 0000000..d91434d --- /dev/null +++ b/yehua/theme.py @@ -0,0 +1 @@ +THEME = {"info": "#F47983"} diff --git a/yehua/thirdparty/__init__.py b/yehua/thirdparty/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/yehua/thirdparty/cutie.py b/yehua/thirdparty/cutie.py new file mode 100644 index 0000000..3a2cb93 --- /dev/null +++ b/yehua/thirdparty/cutie.py @@ -0,0 +1,356 @@ +#! /usr/bin/env python3 +""" +Commandline User Tools for Input Easification +""" + +__version__ = "0.2.2" +__author__ = "Hans / Kamik423" +__license__ = "MIT" + + +import getpass +from typing import List, Optional + +import readchar +from colorama import init + +init() + + +class DefaultKeys: + """List of default keybindings. + Attributes: + interrupt(List[str]): Keys that cause a keyboard interrupt. + select(List[str]): Keys that trigger list element selection. + confirm(List[str]): Keys that trigger list confirmation. + delete(List[str]): Keys that trigger character deletion. + down(List[str]): Keys that select the element below. + up(List[str]): Keys that select the element above. + """ + + interrupt: List[str] = [readchar.key.CTRL_C, readchar.key.CTRL_D] + select: List[str] = [readchar.key.SPACE] + confirm: List[str] = [readchar.key.ENTER] + delete: List[str] = [readchar.key.BACKSPACE] + down: List[str] = [readchar.key.DOWN, "j"] + up: List[str] = [readchar.key.UP, "k"] + + +def get_number( + prompt: str, + min_value: Optional[float] = None, + max_value: Optional[float] = None, + allow_float: bool = True, +) -> float: + """Get a number from user input. + If an invalid number is entered the user will be prompted again. + Args: + prompt (str): The prompt asking the user to input. + min_value (float, optional): The [inclusive] minimum value. + max_value (float, optional): The [inclusive] maximum value. + allow_float (bool, optional): Allow floats or force integers. + Returns: + float: The number input by the user. + """ + return_value: Optional[float] = None + while return_value is None: + input_value = input(prompt + " ") + try: + return_value = float(input_value) + except ValueError: + print("Not a valid number.\033[K\033[1A\r\033[K", end="") + if not allow_float and return_value is not None: + if return_value != int(return_value): + print("Has to be an integer.\033[K\033[1A\r\033[K", end="") + return_value = None + if min_value is not None and return_value is not None: + if return_value < min_value: + print( + f"Has to be at least {min_value}.\033[K\033[1A\r\033[K", + end="", + ) + return_value = None + if max_value is not None and return_value is not None: + if return_value > max_value: + print(f"Has to be at most {max_value}.\033[1A\r\033[K", end="") + return_value = None + if return_value is not None: + break + print("\033[K", end="") + if allow_float: + return return_value + return int(return_value) + + +def secure_input(prompt: str) -> str: + """Get secure input without showing it in the command line. + Args: + prompt (str): The prompt asking the user to input. + Returns: + str: The secure input. + """ + return getpass.getpass(prompt + " ") + + +def select( + options: List[str], + caption_indices: Optional[List[int]] = None, + deselected_prefix: str = "\033[1m[ ]\033[0m ", + selected_prefix: str = "\033[1m[\033[32;1mx\033[0;1m]\033[0m ", + caption_prefix: str = "", + selected_index: int = 0, + confirm_on_select: bool = True, +) -> int: + """Select an option from a list. + Args: + options (List[str]): The options to select from. + caption_indices (List[int], optional): Non-selectable indices. + deselected_prefix (str, optional): Prefix for deselected option ([ ]). + selected_prefix (str, optional): Prefix for selected option ([x]). + caption_prefix (str, optional): Prefix for captions (). + selected_index (int, optional): The index to be selected at first. + confirm_on_select (bool, optional): Select keys also confirm. + Returns: + int: The index that has been selected. + """ + print("\n" * (len(options) - 1)) + if caption_indices is None: + caption_indices = [] + while True: + print(f"\033[{len(options) + 1}A") + for i, option in enumerate(options): + if i not in caption_indices: + print( + "\033[K{}{}".format( + selected_prefix + if i == selected_index + else deselected_prefix, + option, + ) + ) + elif i in caption_indices: + print("\033[K{}{}".format(caption_prefix, options[i])) + keypress = readchar.readkey() + if keypress in DefaultKeys.up: + new_index = selected_index + while new_index > 0: + new_index -= 1 + if new_index not in caption_indices: + selected_index = new_index + break + elif keypress in DefaultKeys.down: + new_index = selected_index + while new_index < len(options) - 1: + new_index += 1 + if new_index not in caption_indices: + selected_index = new_index + break + elif ( + keypress in DefaultKeys.confirm + or confirm_on_select + and keypress in DefaultKeys.select + ): + break + elif keypress in DefaultKeys.interrupt: + raise KeyboardInterrupt + return selected_index + + +def select_multiple( + options: List[str], + caption_indices: Optional[List[int]] = None, + deselected_unticked_prefix: str = "\033[1m( )\033[0m ", + deselected_ticked_prefix: str = "\033[1m(\033[32mx\033[0;1m)\033[0m ", + selected_unticked_prefix: str = "\033[32;1m{ }\033[0m ", + selected_ticked_prefix: str = "\033[32;1m{x}\033[0m ", + caption_prefix: str = "", + ticked_indices: Optional[List[int]] = None, + cursor_index: int = 0, + minimal_count: int = 0, + maximal_count: Optional[int] = None, + hide_confirm: bool = False, + deselected_confirm_label: str = "\033[1m(( confirm ))\033[0m", + selected_confirm_label: str = "\033[1;32m{{ confirm }}\033[0m", +) -> List[int]: + """Select multiple options from a list. + Args: + options (List[str]): The options to select from. + caption_indices (List[int], optional): Non-selectable indices. + deselected_unticked_prefix (str, optional): Prefix for lines that are + not selected and not ticked (( )). + deselected_ticked_prefix (str, optional): Prefix for lines that are + not selected but ticked ((x)). + selected_unticked_prefix (str, optional): Prefix for lines that are + selected but not ticked ({ }). + selected_ticked_prefix (str, optional): Prefix for lines that are + selected and ticked ({x}). + caption_prefix (str, optional): Prefix for captions (). + ticked_indices (List[int], optional): Indices that are + ticked initially. + cursor_index (int, optional): The index the cursor starts at. + minimal_count (int, optional): The minimal amount of lines + that have to be ticked. + maximal_count (int, optional): The maximal amount of lines + that have to be ticked. + hide_confirm (bool, optional): Hide the confirm button. + This causes to confirm the entire selection and not just + tick the line. + deselected_confirm_label (str, optional): The confirm label + if not selected ((( confirm ))). + selected_confirm_label (str, optional): The confirm label + if selected ({{ confirm }}). + Returns: + List[int]: The indices that have been selected + """ + print("\n" * (len(options) - 1)) + if caption_indices is None: + caption_indices = [] + if ticked_indices is None: + ticked_indices = [] + max_index = len(options) - (1 if hide_confirm else 0) + error_message = "" + while True: + print(f"\033[{len(options) + 2}A") + for i, option in enumerate(options): + prefix = "" + if i in caption_indices: + prefix = caption_prefix + elif i == cursor_index: + if i in ticked_indices: + prefix = selected_ticked_prefix + else: + prefix = selected_unticked_prefix + else: + if i in ticked_indices: + prefix = deselected_ticked_prefix + else: + prefix = deselected_unticked_prefix + print("\033[K{}{}".format(prefix, option)) + if hide_confirm: + print(f"{error_message}\033[K") + else: + if cursor_index == max_index: + print(f"{selected_confirm_label} {error_message}\033[K") + else: + print(f"{deselected_confirm_label} {error_message}\033[K") + error_message = "" + keypress = readchar.readkey() + if keypress in DefaultKeys.up: + new_index = cursor_index + while new_index > 0: + new_index -= 1 + if new_index not in caption_indices: + cursor_index = new_index + break + elif keypress in DefaultKeys.down: + new_index = cursor_index + while new_index + 1 <= max_index: + new_index += 1 + if new_index not in caption_indices: + cursor_index = new_index + break + elif keypress in DefaultKeys.select: + if cursor_index in ticked_indices: + if len(ticked_indices) - 1 >= minimal_count: + ticked_indices.remove(cursor_index) + elif maximal_count is not None: + if len(ticked_indices) + 1 <= maximal_count: + ticked_indices.append(cursor_index) + else: + ticked_indices.append(cursor_index) + elif keypress in DefaultKeys.confirm: + if minimal_count > len(ticked_indices): + error_message = f"Must select at least {minimal_count} options" + elif maximal_count is not None and maximal_count < len( + ticked_indices + ): + error_message = f"Must select at most {maximal_count} options" + else: + break + elif keypress in DefaultKeys.interrupt: + raise KeyboardInterrupt + print("\033[1A\033[K", end="", flush=True) + return ticked_indices + + +def prompt_yes_or_no( + question: str, + yes_text: str = "Yes", + no_text: str = "No", + has_to_match_case: bool = False, + enter_empty_confirms: bool = True, + default_is_yes: bool = False, + deselected_prefix: str = " ", + selected_prefix: str = "\033[31m>\033[0m ", + char_prompt: bool = True, +) -> Optional[bool]: + """Prompt the user to input yes or no. + Args: + question (str): The prompt asking the user to input. + yes_text (str, optional): The text corresponding to 'yes'. + no_text (str, optional): The text corresponding to 'no'. + has_to_match_case (bool, optional): Does the case have to match. + enter_empty_confirms (bool, optional): Does enter on empty string work. + default_is_yes (bool, optional): Is yes selected by default (no). + deselected_prefix (str, optional): Prefix if something is deselected. + selected_prefix (str, optional): Prefix if something is selected (> ) + char_prompt (bool, optional): Add a [Y/N] to the prompt. + Returns: + Optional[bool]: The bool what has been selected. + """ + is_yes = default_is_yes + is_selected = enter_empty_confirms + current_message = "" + yn_prompt = f" ({yes_text[0]}/{no_text[0]}) " if char_prompt else ": " + print() + while True: + yes = is_yes and is_selected + no = not is_yes and is_selected + print( + "\033[K" + f"{selected_prefix if yes else deselected_prefix}{yes_text}" + ) + print( + "\033[K" f"{selected_prefix if no else deselected_prefix}{no_text}" + ) + print( + "\033[3A\r\033[K" f"{question}{yn_prompt}{current_message}", + end="", + flush=True, + ) + keypress = readchar.readkey() + if keypress in DefaultKeys.down or keypress in DefaultKeys.up: + is_yes = not is_yes + is_selected = True + current_message = yes_text if is_yes else no_text + elif keypress in DefaultKeys.delete: + if current_message: + current_message = current_message[:-1] + elif keypress in DefaultKeys.interrupt: + raise KeyboardInterrupt + elif keypress in DefaultKeys.confirm: + if is_selected: + break + elif keypress in "\t": + if is_selected: + current_message = yes_text if is_yes else no_text + else: + current_message += keypress + match_yes = yes_text + match_no = no_text + match_text = current_message + if not has_to_match_case: + match_yes = match_yes.upper() + match_no = match_no.upper() + match_text = match_text.upper() + if match_no.startswith(match_text): + is_selected = True + is_yes = False + elif match_yes.startswith(match_text): + is_selected = True + is_yes = True + else: + is_selected = False + print() + print("\033[K\n\033[K\n\033[K\n\033[3A") + return is_selected and is_yes diff --git a/yehua/utils.py b/yehua/utils.py index 323915c..9d3a78e 100644 --- a/yehua/utils.py +++ b/yehua/utils.py @@ -4,8 +4,11 @@ import shutil import logging +from yehua.theme import THEME +from yehua.thirdparty import cutie + import fs -import crayons +import colorful from jinja2 import Environment from ruamel.yaml import YAML @@ -92,6 +95,7 @@ def get_user_inputs(questions): # refactor this later LOG.debug(questions) answers = {} env = Environment() + colorful.update_palette({"peach": "#f47983"}) for q in questions: for key, question in q.items(): if isinstance(question, list): @@ -112,11 +116,27 @@ def get_user_inputs(questions): # refactor this later if match: q, default_answer = match.group(1), match.group(2) decorated_question = ( - f"{q}[{crayons.yellow(default_answer)}]: " + f"{q}[{colorful.peach(default_answer)}]: " ) + if default_answer in ["y", "n"]: + decorated_question = ( + q + f"[{colorful.peach(default_answer)}]" + ) + a = cutie.prompt_yes_or_no( + decorated_question, + default_is_yes=default_answer == "y", + deselected_prefix=" ", + selected_prefix=colorful.bold_peach("\u27a4 "), + char_prompt=False, + ) + if a is None: + raise Exception() + + else: + a = yehua_input(decorated_question) else: decorated_question = question - a = yehua_input(decorated_question) + a = yehua_input(decorated_question) if not a: match = re.match(r".*\[(.*)\].*", question) if match: @@ -129,20 +149,36 @@ def get_user_inputs(questions): # refactor this later def raise_complex_question(question): additional_answers = None for subq in question: - subquestion = subq.pop("question") + question = subq.pop("question") suggested_answers = sorted(subq.keys()) - long_question = [subquestion] + suggested_answers - choice = "Choose from %s [1]: " % ( - ",".join([str(x) for x in range(1, len(long_question))]) + full_question = [question] + suggested_answers + a = cutie.select( + full_question, + caption_indices=[0], + selected_index=1, + deselected_prefix="[ ] ", + selected_prefix=( + colorful.bold_white("[") + + colorful.bold_peach("\u2713") + + colorful.bold_white("] ") + ), ) - long_question.append(choice) - a = yehua_input("\n".join(long_question)) - if not a: - a = "1" + if a is None: + raise Exception() for key in suggested_answers: - if key.startswith(a): + if key.startswith(str(a)): string_answer = key.split(".")[1].strip() if subq[key] != "N/A": additional_answers = get_user_inputs(subq[key]) break return string_answer, additional_answers + + +def color_print(rich_text): + from rich.theme import Theme + from rich.console import Console + + theme = Theme(THEME) + console = Console(theme=theme) + console.print(rich_text) + print("\n")