Merge pull request #1013 from YunoHost/refactoring-yunohost-format-arguments-parsing

WIP: Refactoring yunohost format arguments parsing
This commit is contained in:
Alexandre Aubin 2020-09-10 02:18:51 +02:00 committed by GitHub
commit d1d24cb6eb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 282 additions and 148 deletions

View file

@ -13,6 +13,7 @@
"app_already_up_to_date": "{app:s} is already up-to-date",
"app_argument_choice_invalid": "Use one of these choices '{choices:s}' for the argument '{name:s}'",
"app_argument_invalid": "Pick a valid value for the argument '{name:s}': {error:s}",
"app_argument_password_no_default": "Error while parsing password argument '{name}': password argument can't have a default value for security reason",
"app_argument_required": "Argument '{name:s}' is required",
"app_change_url_failed_nginx_reload": "Could not reload NGINX. Here is the output of 'nginx -t':\n{nginx_errors:s}",
"app_change_url_identical_domains": "The old and new domain/url_path are identical ('{domain:s}{path:s}'), nothing to do.",
@ -599,7 +600,6 @@
"user_unknown": "Unknown user: {user:s}",
"user_update_failed": "Could not update user {user}: {error}",
"user_updated": "User info changed",
"users_available": "Available users:",
"yunohost_already_installed": "YunoHost is already installed",
"yunohost_ca_creation_failed": "Could not create certificate authority",
"yunohost_ca_creation_success": "Local certification authority created.",

View file

@ -2508,6 +2508,225 @@ 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 = getattr(self, "default_value", None) 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:
self._raise_invalide_answer(question)
# 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 _raise_invalide_answer(self, question):
raise YunohostError('app_argument_choice_invalid', name=question.name,
choices=', '.join(question.choices))
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 = ""
forbidden_chars = "{}"
def parse_question(self, question, user_answers):
question = super(PasswordArgumentParser, self).parse_question(question, user_answers)
if question.default is not None:
raise YunohostError('app_argument_password_no_default', name=question.name)
return question
def _post_parse_value(self, question):
if any(char in question.value for char in self.forbidden_chars):
raise YunohostError('pattern_password_app', forbidden_chars=self.forbidden_chars)
from yunohost.utils.password import assert_password_is_strong_enough
assert_password_is_strong_enough('user', question.value)
return super(PasswordArgumentParser, self)._post_parse_value(question)
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
def _raise_invalide_answer(self, question):
raise YunohostError('app_argument_invalid', name=question.name,
error=m18n.n('domain_unknown'))
class UserArgumentParser(YunoHostArgumentFormatParser):
argument_type = "user"
def parse_question(self, question, user_answers):
from yunohost.user import user_list, user_info
from yunohost.domain import _get_maindomain
question = super(UserArgumentParser, self).parse_question(question, user_answers)
question.choices = user_list()["users"]
if question.default is None:
root_mail = "root@%s" % _get_maindomain()
for user in question.choices.keys():
if root_mail in user_info(user).get("mail-aliases", []):
question.default = user
break
return question
def _raise_invalide_answer(self, question):
raise YunohostError('app_argument_invalid', name=question.name,
error=m18n.n('user_unknown', user=question.value))
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
def _raise_invalide_answer(self, question):
raise YunohostError('app_argument_invalid', name=question.name,
error=m18n.n('app_unknown'))
class DisplayTextArgumentParser(YunoHostArgumentFormatParser):
argument_type = "display_text"
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.
@ -2519,128 +2738,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, user_info
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:
if question_type == 'domain':
question_default = _get_maindomain()
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'))
users = user_list()['users']
for user in users.keys():
msignals.display("- {}".format(user))
root_mail = "root@%s" % _get_maindomain()
for user in users.keys():
if root_mail in user_info(user).get("mail-aliases", []):
question_default = user
break
elif question_type == 'password':
msignals.display(m18n.n('good_practices_about_user_password'))
# 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)
is_password = True if question_type == 'password' else False
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

View file

@ -8,7 +8,7 @@ from collections import OrderedDict
from moulinette import msignals
from yunohost import domain, user, app
from yunohost.app import _parse_args_in_yunohost_format
from yunohost.app import _parse_args_in_yunohost_format, PasswordArgumentParser
from yunohost.utils.error import YunohostError
@ -70,7 +70,6 @@ def test_parse_args_in_yunohost_format_string_input():
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@pytest.mark.skip # that shit should work x(
def test_parse_args_in_yunohost_format_string_input_no_ask():
questions = [{"name": "some_string", }]
answers = {}
@ -96,7 +95,6 @@ def test_parse_args_in_yunohost_format_string_optional_with_input():
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@pytest.mark.skip # this should work without ask
def test_parse_args_in_yunohost_format_string_optional_with_input_without_ask():
questions = [{"name": "some_string", "optional": True, }]
answers = {}
@ -237,7 +235,6 @@ def test_parse_args_in_yunohost_format_password_input():
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@pytest.mark.skip # that shit should work x(
def test_parse_args_in_yunohost_format_password_input_no_ask():
questions = [{"name": "some_password", "type": "password", }]
answers = {}
@ -250,8 +247,9 @@ def test_parse_args_in_yunohost_format_password_input_no_ask():
def test_parse_args_in_yunohost_format_password_no_input_optional():
questions = [{"name": "some_password", "type": "password", "optional": True, }]
answers = {}
expected_result = OrderedDict({"some_password": ("", "password")})
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
with pytest.raises(YunohostError):
_parse_args_in_yunohost_format(answers, questions)
def test_parse_args_in_yunohost_format_password_optional_with_input():
@ -270,7 +268,6 @@ def test_parse_args_in_yunohost_format_password_optional_with_input():
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@pytest.mark.skip # this should work without ask
def test_parse_args_in_yunohost_format_password_optional_with_input_without_ask():
questions = [{"name": "some_password", "type": "password", "optional": True, }]
answers = {}
@ -280,7 +277,6 @@ def test_parse_args_in_yunohost_format_password_optional_with_input_without_ask(
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@pytest.mark.skip # this should raises
def test_parse_args_in_yunohost_format_password_no_input_default():
questions = [
{
@ -364,6 +360,39 @@ def test_parse_args_in_yunohost_format_password_input_test_ask_with_help():
assert help_text in prompt.call_args[0][0]
def test_parse_args_in_yunohost_format_password_bad_chars():
questions = [
{
"name": "some_password",
"type": "password",
"ask": "some question",
"example": "some_value",
}
]
for i in PasswordArgumentParser.forbidden_chars:
with pytest.raises(YunohostError):
_parse_args_in_yunohost_format({"some_password": i * 8}, questions)
def test_parse_args_in_yunohost_format_password_strong_enough():
questions = [
{
"name": "some_password",
"type": "password",
"ask": "some question",
"example": "some_value",
}
]
with pytest.raises(YunohostError):
# too short
_parse_args_in_yunohost_format({"some_password": "a"}, questions)
with pytest.raises(YunohostError):
_parse_args_in_yunohost_format({"some_password": "password"}, questions)
def test_parse_args_in_yunohost_format_path():
questions = [{"name": "some_path", "type": "path", }]
answers = {"some_path": "some_value"}
@ -388,7 +417,6 @@ def test_parse_args_in_yunohost_format_path_input():
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@pytest.mark.skip # that shit should work x(
def test_parse_args_in_yunohost_format_path_input_no_ask():
questions = [{"name": "some_path", "type": "path", }]
answers = {}
@ -416,7 +444,6 @@ def test_parse_args_in_yunohost_format_path_optional_with_input():
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@pytest.mark.skip # this should work without ask
def test_parse_args_in_yunohost_format_path_optional_with_input_without_ask():
questions = [{"name": "some_path", "type": "path", "optional": True, }]
answers = {}
@ -604,11 +631,10 @@ def test_parse_args_in_yunohost_format_boolean_input():
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@pytest.mark.skip # we should work
def test_parse_args_in_yunohost_format_boolean_input_no_ask():
questions = [{"name": "some_boolean", "type": "boolean", }]
answers = {}
expected_result = OrderedDict({"some_boolean": ("some_value", "boolean")})
expected_result = OrderedDict({"some_boolean": (1, "boolean")})
with patch.object(msignals, "prompt", return_value="y"):
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@ -660,7 +686,6 @@ def test_parse_args_in_yunohost_format_boolean_no_input_default():
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@pytest.mark.skip # we should raise
def test_parse_args_in_yunohost_format_boolean_bad_default():
questions = [
{
@ -704,16 +729,17 @@ 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", }]
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():
@ -768,7 +794,6 @@ def test_parse_args_in_yunohost_format_domain_two_domains_wrong_answer():
_parse_args_in_yunohost_format(answers, questions)
@pytest.mark.skip # XXX should work
def test_parse_args_in_yunohost_format_domain_two_domains_default_no_ask():
main_domain = "my_main_domain.com"
other_domain = "some_other_domain.tld"
@ -858,6 +883,7 @@ def test_parse_args_in_yunohost_format_user():
expected_result = OrderedDict({"some_user": (username, "user")})
with patch.object(user, "user_list", return_value={"users": users}):
with patch.object(user, "user_info", return_value={}):
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@ -888,12 +914,14 @@ def test_parse_args_in_yunohost_format_user_two_users():
expected_result = OrderedDict({"some_user": (other_user, "user")})
with patch.object(user, "user_list", return_value={"users": users}):
with patch.object(user, "user_info", return_value={}):
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
answers = {"some_user": username}
expected_result = OrderedDict({"some_user": (username, "user")})
with patch.object(user, "user_list", return_value={"users": users}):
with patch.object(user, "user_info", return_value={}):
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@ -983,6 +1011,7 @@ def test_parse_args_in_yunohost_format_user_two_users_default_input():
answers = {}
with patch.object(user, "user_list", return_value={"users": users}):
with patch.object(user, "user_info", return_value={}):
expected_result = OrderedDict({"some_user": (username, "user")})
with patch.object(msignals, "prompt", return_value=username):
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
@ -1020,14 +1049,14 @@ def test_parse_args_in_yunohost_format_app_no_apps():
_parse_args_in_yunohost_format(answers, questions)
@pytest.mark.skip # XXX should work
def test_parse_args_in_yunohost_format_app_no_apps_optional():
apps = []
questions = [{"name": "some_app", "type": "app", "optional": True}]
answers = {}
expected_result = OrderedDict({"some_app": (None, "app")})
with patch.object(app, "app_list", return_value={"apps": apps}):
assert _parse_args_in_yunohost_format(answers, questions) == []
assert _parse_args_in_yunohost_format(answers, questions) == expected_result
def test_parse_args_in_yunohost_format_app():