From 05aa54ac0f2f055aa5401a0d11d8069ce960a894 Mon Sep 17 00:00:00 2001 From: Laurent Peuch Date: Tue, 26 May 2020 06:03:49 +0200 Subject: [PATCH] [mod] refactor the whole argument parsing flow into a component oriented way --- src/yunohost/app.py | 293 +++++++++++------- .../tests/test_apps_arguments_parsing.py | 7 +- 2 files changed, 186 insertions(+), 114 deletions(-) diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 0b16123cb..027a3a558 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -2391,6 +2391,184 @@ def _parse_args_for_action(action, args={}): return _parse_args_in_yunohost_format(args, action_args) +class Question: + "empty class to store questions information" + + +class YunoHostArgumentFormatParser(object): + hide_user_input_in_prompt = False + + def parse_question(self, question, user_answers): + parsed_question = Question() + + parsed_question.name = question['name'] + parsed_question.default = question.get('default', None) + parsed_question.choices = question.get('choices', []) + parsed_question.optional = question.get('optional', False) + parsed_question.ask = question.get('ask') + parsed_question.value = user_answers.get(parsed_question.name) + + if parsed_question.ask is None: + parsed_question.ask = "Enter value for '%s':" % parsed_question.name + + return parsed_question + + def parse(self, question, user_answers): + question = self.parse_question(question, user_answers) + + if question.value is None: + text_for_user_input_in_cli = self._format_text_for_user_input_in_cli(question) + + try: + question.value = msignals.prompt(text_for_user_input_in_cli, self.hide_user_input_in_prompt) + except NotImplementedError: + question.value = None + + # we don't have an answer, check optional and default_value + if question.value is None: + if not question.optional and question.default is None: + raise YunohostError('app_argument_required', name=question.name) + else: + question.value = self.default_value if question.default is None else question.default + + # we have an answer, do some post checks + if question.value is not None: + if question.choices and question.value not in question.choices: + raise YunohostError('app_argument_choice_invalid', name=question.name, + choices=', '.join(question.choices)) + + # this is done to enforce a certain formating like for boolean + # by default it doesn't do anything + question.value = self._post_parse_value(question) + + return (question.value, self.argument_type) + + def _format_text_for_user_input_in_cli(self, question): + text_for_user_input_in_cli = _value_for_locale(question.ask) + + if question.default is not None: + text_for_user_input_in_cli += ' (default: {0})'.format(question.default) + + if question.choices: + text_for_user_input_in_cli += ' [{0}]'.format(' | '.join(question.choices)) + + return text_for_user_input_in_cli + + def _post_parse_value(self, question): + return question.value + + +class StringArgumentParser(YunoHostArgumentFormatParser): + argument_type = "string" + default_value = "" + + +class PasswordArgumentParser(YunoHostArgumentFormatParser): + hide_user_input_in_prompt = True + argument_type = "password" + default_value = "" + + +class PathArgumentParser(YunoHostArgumentFormatParser): + argument_type = "path" + default_value = "" + + +class BooleanArgumentParser(YunoHostArgumentFormatParser): + argument_type = "boolean" + default_value = False + + def parse_question(self, question, user_answers): + question = super(BooleanArgumentParser, self).parse_question(question, user_answers) + + if question.default is None: + question.default = False + + return question + + def _format_text_for_user_input_in_cli(self, question): + text_for_user_input_in_cli = _value_for_locale(question.ask) + + text_for_user_input_in_cli += " [yes | no]" + + if question.default is not None: + formatted_default = "yes" if question.default else "no" + text_for_user_input_in_cli += ' (default: {0})'.format(formatted_default) + + return text_for_user_input_in_cli + + def _post_parse_value(self, question): + if isinstance(question.value, bool): + return 1 if question.value else 0 + + if str(question.value).lower() in ["1", "yes", "y"]: + return 1 + + if str(question.value).lower() in ["0", "no", "n"]: + return 0 + + raise YunohostError('app_argument_choice_invalid', name=question.name, + choices='yes, no, y, n, 1, 0') + + +class DomainArgumentParser(YunoHostArgumentFormatParser): + argument_type = "domain" + + def parse_question(self, question, user_answers): + from yunohost.domain import domain_list, _get_maindomain + + question = super(DomainArgumentParser, self).parse_question(question, user_answers) + + if question.default is None: + question.default = _get_maindomain() + + question.choices = domain_list()["domains"] + + return question + + +class UserArgumentParser(YunoHostArgumentFormatParser): + argument_type = "user" + + def parse_question(self, question, user_answers): + from yunohost.user import user_list + + question = super(UserArgumentParser, self).parse_question(question, user_answers) + question.choices = user_list()["users"] + + return question + + +class AppArgumentParser(YunoHostArgumentFormatParser): + argument_type = "app" + + def parse_question(self, question, user_answers): + from yunohost.app import app_list + + question = super(AppArgumentParser, self).parse_question(question, user_answers) + question.choices = [x["id"] for x in app_list()["apps"]] + + return question + + +class DisplayTextArgumentParser(YunoHostArgumentFormatParser): + + def parse(self, question, user_answers): + print(question["ask"]) + + +ARGUMENTS_TYPE_PARSERS = { + "string": StringArgumentParser, + "password": PasswordArgumentParser, + "path": PathArgumentParser, + "boolean": BooleanArgumentParser, + "domain": DomainArgumentParser, + "user": UserArgumentParser, + "app": AppArgumentParser, + "display_text": DisplayTextArgumentParser, +} + + def _parse_args_in_yunohost_format(user_answers, argument_questions): """Parse arguments store in either manifest.json or actions.json or from a config panel against the user answers when they are present. @@ -2402,121 +2580,14 @@ def _parse_args_in_yunohost_format(user_answers, argument_questions): format from actions.json/toml, manifest.json/toml or config_panel.json/toml """ - from yunohost.domain import domain_list, _get_maindomain - from yunohost.user import user_list - parsed_answers_dict = OrderedDict() for question in argument_questions: - question_name = question['name'] - question_type = question.get('type', 'string') - question_default = question.get('default', None) - question_choices = question.get('choices', []) - question_value = None + parser = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]() - # Transpose default value for boolean type and set it to - # false if not defined. - if question_type == 'boolean': - question_default = 1 if question_default else 0 - - # do not print for webadmin - if question_type == 'display_text' and msettings.get('interface') != 'api': - print(_value_for_locale(question['ask'])) - continue - - # Attempt to retrieve argument value - if question_name in user_answers: - question_value = user_answers[question_name] - else: - if 'ask' in question: - # Retrieve proper ask string - text_for_user_input_in_cli = _value_for_locale(question['ask']) - - # Append extra strings - if question_type == 'boolean': - text_for_user_input_in_cli += ' [yes | no]' - elif question_choices: - text_for_user_input_in_cli += ' [{0}]'.format(' | '.join(question_choices)) - - if question_default is not None: - if question_type == 'boolean': - text_for_user_input_in_cli += ' (default: {0})'.format("yes" if question_default == 1 else "no") - else: - text_for_user_input_in_cli += ' (default: {0})'.format(question_default) - - # Check for a password argument - is_password = True if question_type == 'password' else False - - if question_type == 'domain': - question_default = _get_maindomain() - text_for_user_input_in_cli += ' (default: {0})'.format(question_default) - msignals.display(m18n.n('domains_available')) - for domain in domain_list()['domains']: - msignals.display("- {}".format(domain)) - - elif question_type == 'user': - msignals.display(m18n.n('users_available')) - for user in user_list()['users'].keys(): - msignals.display("- {}".format(user)) - - elif question_type == 'password': - msignals.display(m18n.n('good_practices_about_user_password')) - - try: - input_string = msignals.prompt(text_for_user_input_in_cli, is_password) - except NotImplementedError: - input_string = None - if (input_string == '' or input_string is None) \ - and question_default is not None: - question_value = question_default - else: - question_value = input_string - elif question_default is not None: - question_value = question_default - - # If the value is empty (none or '') - # then check if question is optional or not - if question_value is None or question_value == '': - if question.get("optional", False): - # Argument is optional, keep an empty value - # and that's all for this question! - parsed_answers_dict[question_name] = ('', question_type) - continue - else: - # The argument is required ! - raise YunohostError('app_argument_required', name=question_name) - - # Validate argument choice - if question_choices and question_value not in question_choices: - raise YunohostError('app_argument_choice_invalid', name=question_name, choices=', '.join(question_choices)) - - # Validate argument type - if question_type == 'domain': - if question_value not in domain_list()['domains']: - raise YunohostError('app_argument_invalid', name=question_name, error=m18n.n('domain_unknown')) - elif question_type == 'user': - if question_value not in user_list()["users"].keys(): - raise YunohostError('app_argument_invalid', name=question_name, error=m18n.n('user_unknown', user=question_value)) - elif question_type == 'app': - if not _is_installed(question_value): - raise YunohostError('app_argument_invalid', name=question_name, error=m18n.n('app_unknown')) - elif question_type == 'boolean': - if isinstance(question_value, bool): - question_value = 1 if question_value else 0 - else: - if str(question_value).lower() in ["1", "yes", "y"]: - question_value = 1 - elif str(question_value).lower() in ["0", "no", "n"]: - question_value = 0 - else: - raise YunohostError('app_argument_choice_invalid', name=question_name, choices='yes, no, y, n, 1, 0') - elif question_type == 'password': - forbidden_chars = "{}" - if any(char in question_value for char in forbidden_chars): - raise YunohostError('pattern_password_app', forbidden_chars=forbidden_chars) - from yunohost.utils.password import assert_password_is_strong_enough - assert_password_is_strong_enough('user', question_value) - parsed_answers_dict[question_name] = (question_value, question_type) + answer = parser.parse(question=question, user_answers=user_answers) + if answer is not None: + parsed_answers_dict[question["name"]] = answer return parsed_answers_dict diff --git a/src/yunohost/tests/test_apps_arguments_parsing.py b/src/yunohost/tests/test_apps_arguments_parsing.py index a3d5b7f09..c127cc414 100644 --- a/src/yunohost/tests/test_apps_arguments_parsing.py +++ b/src/yunohost/tests/test_apps_arguments_parsing.py @@ -705,15 +705,16 @@ def test_parse_args_in_yunohost_format_boolean_input_test_ask_with_default(): def test_parse_args_in_yunohost_format_domain_empty(): questions = [{"name": "some_domain", "type": "domain",}] + main_domain = "my_main_domain.com" + expected_result = OrderedDict({"some_domain": (main_domain, "domain")}) answers = {} with patch.object( domain, "_get_maindomain", return_value="my_main_domain.com" ), patch.object( - domain, "domain_list", return_value={"domains": ["my_main_domain.com"]} + domain, "domain_list", return_value={"domains": [main_domain]} ): - with pytest.raises(YunohostError): - _parse_args_in_yunohost_format(answers, questions) + assert _parse_args_in_yunohost_format(answers, questions) == expected_result def test_parse_args_in_yunohost_format_domain():