diff --git a/src/app.py b/src/app.py index a77cf51b8..6cc21f404 100644 --- a/src/app.py +++ b/src/app.py @@ -1099,7 +1099,7 @@ def app_install( raw_questions = manifest["install"] questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args) args = { - question.name: question.value + question.id: question.value for question in questions if question.value is not None } @@ -1147,7 +1147,7 @@ def app_install( if question.type == "password": continue - app_settings[question.name] = question.value + app_settings[question.id] = question.value _set_app_settings(app_instance_name, app_settings) @@ -1202,17 +1202,17 @@ def app_install( # Reinject user-provider passwords which are not in the app settings # (cf a few line before) if question.type == "password": - env_dict[question.name] = question.value + env_dict[question.id] = question.value # We want to hav the env_dict in the log ... but not password values env_dict_for_logging = env_dict.copy() for question in questions: # Or should it be more generally question.redact ? if question.type == "password": - if f"YNH_APP_ARG_{question.name.upper()}" in env_dict_for_logging: - del env_dict_for_logging[f"YNH_APP_ARG_{question.name.upper()}"] - if question.name in env_dict_for_logging: - del env_dict_for_logging[question.name] + if f"YNH_APP_ARG_{question.id.upper()}" in env_dict_for_logging: + del env_dict_for_logging[f"YNH_APP_ARG_{question.id.upper()}"] + if question.id in env_dict_for_logging: + del env_dict_for_logging[question.id] operation_logger.extra.update({"env": env_dict_for_logging}) @@ -2376,17 +2376,17 @@ def _set_default_ask_questions(questions, script_name="install"): ), # i18n: app_manifest_install_ask_init_admin_permission ] - for question_name, question in questions.items(): - question["name"] = question_name + for question_id, question in questions.items(): + question["id"] = question_id # If this question corresponds to a question with default ask message... if any( - (question.get("type"), question["name"]) == question_with_default + (question.get("type"), question["id"]) == question_with_default for question_with_default in questions_with_default ): # The key is for example "app_manifest_install_ask_domain" question["ask"] = m18n.n( - f"app_manifest_{script_name}_ask_{question['name']}" + f"app_manifest_{script_name}_ask_{question['id']}" ) # Also it in fact doesn't make sense for any of those questions to have an example value nor a default value... diff --git a/src/tests/test_questions.py b/src/tests/test_questions.py index e23be9925..a9b61aad9 100644 --- a/src/tests/test_questions.py +++ b/src/tests/test_questions.py @@ -17,7 +17,9 @@ from yunohost import app, domain, user from yunohost.utils.form import ( OPTIONS, ask_questions_and_parse_answers, - DisplayTextOption, + BaseChoicesOption, + BaseInputOption, + BaseReadonlyOption, PasswordOption, DomainOption, WebPathOption, @@ -31,7 +33,7 @@ from yunohost.utils.error import YunohostError, YunohostValidationError """ Argument default format: { - "the_name": { + "the_id": { "type": "one_of_the_available_type", // "sting" is not specified "ask": { "en": "the question in english", @@ -48,7 +50,7 @@ Argument default format: } User answers: -{"the_name": "value", ...} +{"the_id": "value", ...} """ @@ -377,8 +379,7 @@ def _fill_or_prompt_one_option(raw_option, intake): answers = {id_: intake} if intake is not None else {} option = ask_questions_and_parse_answers(options, answers)[0] - - return (option, option.value) + return (option, option.value if isinstance(option, BaseInputOption) else None) def _test_value_is_expected_output(value, expected_output): @@ -438,14 +439,14 @@ class BaseTest: id_ = raw_option["id"] option, value = _fill_or_prompt_one_option(raw_option, None) - is_special_readonly_option = isinstance(option, DisplayTextOption) + is_special_readonly_option = isinstance(option, BaseReadonlyOption) assert isinstance(option, OPTIONS[raw_option["type"]]) assert option.type == raw_option["type"] - assert option.name == id_ + assert option.id == id_ assert option.ask == {"en": id_} assert option.readonly is (True if is_special_readonly_option else False) - assert option.visible is None + assert option.visible is True # assert option.bind is None if is_special_readonly_option: @@ -489,14 +490,12 @@ class BaseTest: option, value = _fill_or_prompt_one_option(raw_option, None) expected_message = option.ask["en"] + choices = [] - if option.choices: - choices = ( - option.choices - if isinstance(option.choices, list) - else option.choices.keys() - ) - expected_message += f" [{' | '.join(choices)}]" + if isinstance(option, BaseChoicesOption): + choices = option.choices + if choices: + expected_message += f" [{' | '.join(choices)}]" if option.type == "boolean": expected_message += " [yes | no]" @@ -506,7 +505,7 @@ class BaseTest: confirm=False, # FIXME no confirm? prefill=prefill, is_multiline=option.type == "text", - autocomplete=option.choices or [], + autocomplete=choices, help=option.help["en"], ) @@ -661,9 +660,7 @@ class TestString(BaseTest): (" ##value \n \tvalue\n ", "##value \n \tvalue"), ], reason=r"should fail or without `\n`?"), # readonly - *xfail(scenarios=[ - ("overwrite", "expected value", {"readonly": True, "default": "expected value"}), - ], reason="Should not be overwritten"), + ("overwrite", "expected value", {"readonly": True, "current_value": "expected value"}), ] # fmt: on @@ -700,9 +697,7 @@ class TestText(BaseTest): (r" ##value \n \tvalue\n ", r"##value \n \tvalue\n"), ], reason="Should not be stripped"), # readonly - *xfail(scenarios=[ - ("overwrite", "expected value", {"readonly": True, "default": "expected value"}), - ], reason="Should not be overwritten"), + ("overwrite", "expected value", {"readonly": True, "current_value": "expected value"}), ] # fmt: on @@ -736,9 +731,7 @@ class TestPassword(BaseTest): ("secret", FAIL), *[("supersecret" + char, FAIL) for char in PasswordOption.forbidden_chars], # FIXME maybe add ` \n` to the list? # readonly - *xpass(scenarios=[ - ("s3cr3t!!", "s3cr3t!!", {"readonly": True}), - ], reason="Should fail since readonly is forbidden"), + ("s3cr3t!!", YunohostError, {"readonly": True, "current_value": "isforbidden"}), # readonly is forbidden ] # fmt: on @@ -779,9 +772,7 @@ class TestColor(BaseTest): ("yellow", "#ffff00"), ], reason="Should work with pydantic"), # readonly - *xfail(scenarios=[ - ("#ffff00", "#fe100", {"readonly": True, "default": "#fe100"}), - ], reason="Should not be overwritten"), + ("#ffff00", "#fe100", {"readonly": True, "current_value": "#fe100"}), ] # fmt: on @@ -823,9 +814,7 @@ class TestNumber(BaseTest): (-10, -10, {"default": 10}), (-10, -10, {"default": 10, "optional": True}), # readonly - *xfail(scenarios=[ - (1337, 10000, {"readonly": True, "default": 10000}), - ], reason="Should not be overwritten"), + (1337, 10000, {"readonly": True, "current_value": 10000}), ] # fmt: on # FIXME should `step` be some kind of "multiple of"? @@ -890,9 +879,7 @@ class TestBoolean(BaseTest): "scenarios": all_fails("", "y", "n", error=AssertionError), }, # readonly - *xfail(scenarios=[ - (1, 0, {"readonly": True, "default": 0}), - ], reason="Should not be overwritten"), + (1, 0, {"readonly": True, "current_value": 0}), ] @@ -930,9 +917,7 @@ class TestDate(BaseTest): ("12-01-10", FAIL), ("2022-02-29", FAIL), # readonly - *xfail(scenarios=[ - ("2070-12-31", "2024-02-29", {"readonly": True, "default": "2024-02-29"}), - ], reason="Should not be overwritten"), + ("2070-12-31", "2024-02-29", {"readonly": True, "current_value": "2024-02-29"}), ] # fmt: on @@ -965,9 +950,7 @@ class TestTime(BaseTest): ("23:1", FAIL), ("23:005", FAIL), # readonly - *xfail(scenarios=[ - ("00:00", "08:00", {"readonly": True, "default": "08:00"}), - ], reason="Should not be overwritten"), + ("00:00", "08:00", {"readonly": True, "current_value": "08:00"}), ] # fmt: on @@ -991,9 +974,7 @@ class TestEmail(BaseTest): *nones(None, "", output=""), ("\n Abc@example.tld ", "Abc@example.tld"), # readonly - *xfail(scenarios=[ - ("Abc@example.tld", "admin@ynh.local", {"readonly": True, "default": "admin@ynh.local"}), - ], reason="Should not be overwritten"), + ("Abc@example.tld", "admin@ynh.local", {"readonly": True, "current_value": "admin@ynh.local"}), # Next examples are from https://github.com/JoshData/python-email-validator/blob/main/tests/test_syntax.py # valid email values @@ -1106,9 +1087,7 @@ class TestWebPath(BaseTest): ("https://example.com/folder", "/https://example.com/folder") ], reason="Should fail or scheme+domain removed"), # readonly - *xfail(scenarios=[ - ("/overwrite", "/value", {"readonly": True, "default": "/value"}), - ], reason="Should not be overwritten"), + ("/overwrite", "/value", {"readonly": True, "current_value": "/value"}), # FIXME should path have forbidden_chars? ] # fmt: on @@ -1133,9 +1112,7 @@ class TestUrl(BaseTest): *nones(None, "", output=""), ("http://some.org/folder/file.txt", "http://some.org/folder/file.txt"), # readonly - *xfail(scenarios=[ - ("https://overwrite.org", "https://example.org", {"readonly": True, "default": "https://example.org"}), - ], reason="Should not be overwritten"), + ("https://overwrite.org", "https://example.org", {"readonly": True, "current_value": "https://example.org"}), # rest is taken from https://github.com/pydantic/pydantic/blob/main/tests/test_networks.py # valid *unchanged( @@ -1425,9 +1402,7 @@ class TestSelect(BaseTest): ] }, # readonly - *xfail(scenarios=[ - ("one", "two", {"readonly": True, "choices": ["one", "two"], "default": "two"}), - ], reason="Should not be overwritten"), + ("one", "two", {"readonly": True, "choices": ["one", "two"], "current_value": "two"}), ] # fmt: on @@ -1475,9 +1450,7 @@ class TestTags(BaseTest): *all_fails(*([t] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}), *all_fails(*([str(t)] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}), # readonly - *xfail(scenarios=[ - ("one", "one,two", {"readonly": True, "choices": ["one", "two"], "default": "one,two"}), - ], reason="Should not be overwritten"), + ("one", "one,two", {"readonly": True, "choices": ["one", "two"], "current_value": "one,two"}), ] # fmt: on @@ -1525,9 +1498,7 @@ class TestDomain(BaseTest): ("doesnt_exist.pouet", FAIL, {}), ("fake.com", FAIL, {"choices": ["fake.com"]}), # readonly - *xpass(scenarios=[ - (domains1[0], domains1[0], {"readonly": True}), - ], reason="Should fail since readonly is forbidden"), + (domains1[0], YunohostError, {"readonly": True}), # readonly is forbidden ] }, { @@ -1628,9 +1599,7 @@ class TestApp(BaseTest): (installed_non_webapp["id"], installed_non_webapp["id"]), (installed_non_webapp["id"], FAIL, {"filter": "is_webapp"}), # readonly - *xpass(scenarios=[ - (installed_non_webapp["id"], installed_non_webapp["id"], {"readonly": True}), - ], reason="Should fail since readonly is forbidden"), + (installed_non_webapp["id"], YunohostError, {"readonly": True}), # readonly is forbidden ] }, ] @@ -1747,9 +1716,7 @@ class TestUser(BaseTest): ("", regular_username, {"default": regular_username}) ], reason="Should throw 'no default allowed'"), # readonly - *xpass(scenarios=[ - (admin_username, admin_username, {"readonly": True}), - ], reason="Should fail since readonly is forbidden"), + (admin_username, YunohostError, {"readonly": True}), # readonly is forbidden ] }, ] @@ -1833,9 +1800,7 @@ class TestGroup(BaseTest): ("", "custom_group", {"default": "custom_group"}), ], reason="Should throw 'default must be in (None, 'all_users', 'visitors', 'admins')"), # readonly - *xpass(scenarios=[ - ("admins", "admins", {"readonly": True}), - ], reason="Should fail since readonly is forbidden"), + ("admins", YunohostError, {"readonly": True}), # readonly is forbidden ] }, ] @@ -1961,7 +1926,7 @@ def test_options_query_string(): ) def _assert_correct_values(options, raw_options): - form = {option.name: option.value for option in options} + form = {option.id: option.value for option in options} for k, v in results.items(): if k == "file_id": @@ -1993,11 +1958,26 @@ def test_question_string_default_type(): out = ask_questions_and_parse_answers(questions, answers)[0] - assert out.name == "some_string" + assert out.id == "some_string" assert out.type == "string" assert out.value == "some_value" +def test_option_default_type_with_choices_is_select(): + questions = { + "some_choices": {"choices": ["a", "b"]}, + # LEGACY (`choices` in option `string` used to be valid) + # make sure this result as a `select` option + "some_legacy": {"type": "string", "choices": ["a", "b"]} + } + answers = {"some_choices": "a", "some_legacy": "a"} + + options = ask_questions_and_parse_answers(questions, answers) + for option in options: + assert option.type == "select" + assert option.value == "a" + + @pytest.mark.skip # we should do something with this example def test_question_string_input_test_ask_with_example(): ask_text = "some question" @@ -2018,75 +1998,6 @@ def test_question_string_input_test_ask_with_example(): assert example_text in prompt.call_args[1]["message"] -def test_question_string_with_choice(): - questions = {"some_string": {"type": "string", "choices": ["fr", "en"]}} - answers = {"some_string": "fr"} - out = ask_questions_and_parse_answers(questions, answers)[0] - - assert out.name == "some_string" - assert out.type == "string" - assert out.value == "fr" - - -def test_question_string_with_choice_prompt(): - questions = {"some_string": {"type": "string", "choices": ["fr", "en"]}} - answers = {"some_string": "fr"} - with patch.object(Moulinette, "prompt", return_value="fr"), patch.object( - os, "isatty", return_value=True - ): - out = ask_questions_and_parse_answers(questions, answers)[0] - - assert out.name == "some_string" - assert out.type == "string" - assert out.value == "fr" - - -def test_question_string_with_choice_bad(): - questions = {"some_string": {"type": "string", "choices": ["fr", "en"]}} - answers = {"some_string": "bad"} - - with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False): - ask_questions_and_parse_answers(questions, answers) - - -def test_question_string_with_choice_ask(): - ask_text = "some question" - choices = ["fr", "en", "es", "it", "ru"] - questions = { - "some_string": { - "ask": ask_text, - "choices": choices, - } - } - answers = {} - - with patch.object(Moulinette, "prompt", return_value="ru") as prompt, patch.object( - os, "isatty", return_value=True - ): - ask_questions_and_parse_answers(questions, answers) - assert ask_text in prompt.call_args[1]["message"] - - for choice in choices: - assert choice in prompt.call_args[1]["message"] - - -def test_question_string_with_choice_default(): - questions = { - "some_string": { - "type": "string", - "choices": ["fr", "en"], - "default": "en", - } - } - answers = {} - with patch.object(os, "isatty", return_value=False): - out = ask_questions_and_parse_answers(questions, answers)[0] - - assert out.name == "some_string" - assert out.type == "string" - assert out.value == "en" - - @pytest.mark.skip # we should do something with this example def test_question_password_input_test_ask_with_example(): ask_text = "some question" diff --git a/src/utils/configpanel.py b/src/utils/configpanel.py index 2c56eb754..42a030cbc 100644 --- a/src/utils/configpanel.py +++ b/src/utils/configpanel.py @@ -30,8 +30,11 @@ from moulinette.utils.log import getActionLogger from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.form import ( OPTIONS, + BaseChoicesOption, + BaseInputOption, BaseOption, FileOption, + OptionType, ask_questions_and_parse_answers, evaluate_simple_js_expression, ) @@ -146,15 +149,17 @@ class ConfigPanel: if mode == "full": option["ask"] = ask - question_class = OPTIONS[option.get("type", "string")] + question_class = OPTIONS[option.get("type", OptionType.string)] # FIXME : maybe other properties should be taken from the question, not just choices ?. - option["choices"] = question_class(option).choices - option["default"] = question_class(option).default - option["pattern"] = question_class(option).pattern + if issubclass(question_class, BaseChoicesOption): + option["choices"] = question_class(option).choices + if issubclass(question_class, BaseInputOption): + option["default"] = question_class(option).default + option["pattern"] = question_class(option).pattern else: result[key] = {"ask": ask} if "current_value" in option: - question_class = OPTIONS[option.get("type", "string")] + question_class = OPTIONS[option.get("type", OptionType.string)] result[key]["value"] = question_class.humanize( option["current_value"], option ) @@ -239,7 +244,7 @@ class ConfigPanel: self.filter_key = "" self._get_config_panel() for panel, section, option in self._iterate(): - if option["type"] == "button": + if option["type"] == OptionType.button: key = f"{panel['id']}.{section['id']}.{option['id']}" actions[key] = _value_for_locale(option["ask"]) @@ -421,7 +426,7 @@ class ConfigPanel: subnode["name"] = key # legacy subnode.setdefault("optional", raw_infos.get("optional", True)) # If this section contains at least one button, it becomes an "action" section - if subnode.get("type") == "button": + if subnode.get("type") == OptionType.button: out["is_action_section"] = True out.setdefault(sublevel, []).append(subnode) # Key/value are a property @@ -465,20 +470,10 @@ class ConfigPanel: "max_progression", ] forbidden_keywords += format_description["sections"] - forbidden_readonly_types = ["password", "app", "domain", "user", "file"] for _, _, option in self._iterate(): if option["id"] in forbidden_keywords: raise YunohostError("config_forbidden_keyword", keyword=option["id"]) - if ( - option.get("readonly", False) - and option.get("type", "string") in forbidden_readonly_types - ): - raise YunohostError( - "config_forbidden_readonly_type", - type=option["type"], - id=option["id"], - ) return self.config @@ -506,13 +501,13 @@ class ConfigPanel: # Hydrating config panel with current value for _, section, option in self._iterate(): if option["id"] not in self.values: - allowed_empty_types = [ - "alert", - "display_text", - "markdown", - "file", - "button", - ] + allowed_empty_types = { + OptionType.alert, + OptionType.display_text, + OptionType.markdown, + OptionType.file, + OptionType.button, + } if section["is_action_section"] and option.get("default") is not None: self.values[option["id"]] = option["default"] @@ -526,7 +521,7 @@ class ConfigPanel: f"Config panel question '{option['id']}' should be initialized with a value during install or upgrade.", raw_msg=True, ) - value = self.values[option["name"]] + value = self.values[option["id"]] # Allow to use value instead of current_value in app config script. # e.g. apps may write `echo 'value: "foobar"'` in the config file (which is more intuitive that `echo 'current_value: "foobar"'` @@ -593,7 +588,7 @@ class ConfigPanel: section["options"] = [ option for option in section["options"] - if option.get("type", "string") != "button" + if option.get("type", OptionType.string) != OptionType.button or option["id"] == action ] @@ -605,14 +600,14 @@ class ConfigPanel: prefilled_answers.update(self.new_values) questions = ask_questions_and_parse_answers( - {question["name"]: question for question in section["options"]}, + {question["id"]: question for question in section["options"]}, prefilled_answers=prefilled_answers, current_values=self.values, hooks=self.hooks, ) self.new_values.update( { - question.name: question.value + question.id: question.value for question in questions if question.value is not None } diff --git a/src/utils/form.py b/src/utils/form.py index 12c3249c3..1ca03373e 100644 --- a/src/utils/form.py +++ b/src/utils/form.py @@ -23,7 +23,8 @@ import re import shutil import tempfile import urllib.parse -from typing import Any, Callable, Dict, List, Mapping, Optional, Union +from enum import Enum +from typing import Any, Callable, Dict, List, Literal, Mapping, Optional, Union from moulinette import Moulinette, m18n from moulinette.interfaces.cli import colorize @@ -35,6 +36,7 @@ from yunohost.utils.i18n import _value_for_locale logger = getActionLogger("yunohost.form") +Context = dict[str, Any] # ╭───────────────────────────────────────────────────────╮ # │ ┌─╴╷ ╷╭─┐╷ │ @@ -193,33 +195,158 @@ def evaluate_simple_js_expression(expr, context={}): # ╰───────────────────────────────────────────────────────╯ -class BaseOption: - hide_user_input_in_prompt = False - pattern: Optional[Dict] = None +class OptionType(str, Enum): + # display + display_text = "display_text" + markdown = "markdown" + alert = "alert" + # action + button = "button" + # text + string = "string" + text = "text" + password = "password" + color = "color" + # numeric + number = "number" + range = "range" + # boolean + boolean = "boolean" + # time + date = "date" + time = "time" + # location + email = "email" + path = "path" + url = "url" + # file + file = "file" + # choice + select = "select" + tags = "tags" + # entity + domain = "domain" + app = "app" + user = "user" + group = "group" + +FORBIDDEN_READONLY_TYPES = { + OptionType.password, + OptionType.app, + OptionType.domain, + OptionType.user, + OptionType.group, +} + + +class BaseOption: def __init__( self, question: Dict[str, Any], - context: Mapping[str, Any] = {}, - hooks: Dict[str, Callable] = {}, ): - self.name = question["name"] - self.context = context - self.hooks = hooks - self.type = question.get("type", "string") - self.default = question.get("default", None) - self.optional = question.get("optional", False) - self.visible = question.get("visible", None) + self.id = question["id"] + self.type = question.get("type", OptionType.string) + self.visible = question.get("visible", True) + self.readonly = question.get("readonly", False) - # Don't restrict choices if there's none specified - self.choices = question.get("choices", None) - self.pattern = question.get("pattern", self.pattern) - self.ask = question.get("ask", self.name) + if self.readonly and self.type in FORBIDDEN_READONLY_TYPES: + # FIXME i18n + raise YunohostError( + "config_forbidden_readonly_type", + type=self.type, + id=self.id, + ) + + self.ask = question.get("ask", self.id) if not isinstance(self.ask, dict): self.ask = {"en": self.ask} + + def is_visible(self, context: Context) -> bool: + if isinstance(self.visible, bool): + return self.visible + + return evaluate_simple_js_expression(self.visible, context=context) + + def _get_prompt_message(self) -> str: + return _value_for_locale(self.ask) + + +# ╭───────────────────────────────────────────────────────╮ +# │ DISPLAY OPTIONS │ +# ╰───────────────────────────────────────────────────────╯ + + +class BaseReadonlyOption(BaseOption): + def __init__(self, question): + super().__init__(question) + self.readonly = True + + +class DisplayTextOption(BaseReadonlyOption): + type: Literal[OptionType.display_text] = OptionType.display_text + + +class MarkdownOption(BaseReadonlyOption): + type: Literal[OptionType.markdown] = OptionType.markdown + + +class AlertOption(BaseReadonlyOption): + type: Literal[OptionType.alert] = OptionType.alert + + def __init__(self, question): + super().__init__(question) + self.style = question.get("style", "info") + + def _get_prompt_message(self) -> str: + text = _value_for_locale(self.ask) + + if self.style in ["success", "info", "warning", "danger"]: + color = { + "success": "green", + "info": "cyan", + "warning": "yellow", + "danger": "red", + } + prompt = m18n.g(self.style) if self.style != "danger" else m18n.n("danger") + return colorize(prompt, color[self.style]) + f" {text}" + else: + return text + + +class ButtonOption(BaseReadonlyOption): + type: Literal[OptionType.button] = OptionType.button + enabled = True + + def __init__(self, question): + super().__init__(question) + self.help = question.get("help") + self.style = question.get("style", "success") + self.enabled = question.get("enabled", True) + + def is_enabled(self, context: Context) -> bool: + if isinstance(self.enabled, bool): + return self.enabled + + return evaluate_simple_js_expression(self.enabled, context=context) + + +# ╭───────────────────────────────────────────────────────╮ +# │ INPUT OPTIONS │ +# ╰───────────────────────────────────────────────────────╯ + + +class BaseInputOption(BaseOption): + hide_user_input_in_prompt = False + pattern: Optional[Dict] = None + + def __init__(self, question: Dict[str, Any]): + super().__init__(question) + self.default = question.get("default", None) + self.optional = question.get("optional", False) + self.pattern = question.get("pattern", self.pattern) self.help = question.get("help") self.redact = question.get("redact", False) - self.filter = question.get("filter", None) # .current_value is the currently stored value self.current_value = question.get("current_value") # .value is the "proposed" value which we got from the user @@ -241,125 +368,25 @@ class BaseOption: value = value.strip() return value - def ask_if_needed(self): - if self.visible and not evaluate_simple_js_expression( - self.visible, context=self.context - ): - # FIXME There could be several use case if the question is not displayed: - # - we doesn't want to give a specific value - # - we want to keep the previous value - # - we want the default value - self.value = self.values[self.name] = None - return self.values - - for i in range(5): - # Display question if no value filled or if it's a readonly message - if Moulinette.interface.type == "cli" and os.isatty(1): - text_for_user_input_in_cli = self._format_text_for_user_input_in_cli() - if self.readonly: - Moulinette.display(text_for_user_input_in_cli) - self.value = self.values[self.name] = self.current_value - return self.values - elif self.value is None: - self._prompt(text_for_user_input_in_cli) - - # Apply default value - class_default = getattr(self, "default_value", None) - if self.value in [None, ""] and ( - self.default is not None or class_default is not None - ): - self.value = class_default if self.default is None else self.default - - try: - # Normalize and validate - self.value = self.normalize(self.value, self) - self._value_pre_validator() - except YunohostValidationError as e: - # If in interactive cli, re-ask the current question - if i < 4 and Moulinette.interface.type == "cli" and os.isatty(1): - logger.error(str(e)) - self.value = None - continue - - # Otherwise raise the ValidationError - raise - - break - - self.value = self.values[self.name] = self._value_post_validator() - - # Search for post actions in hooks - post_hook = f"post_ask__{self.name}" - if post_hook in self.hooks: - self.values.update(self.hooks[post_hook](self)) - - return self.values - - def _prompt(self, text): - prefill = "" - if self.current_value is not None: - prefill = self.humanize(self.current_value, self) - elif self.default is not None: - prefill = self.humanize(self.default, self) - self.value = Moulinette.prompt( - message=text, - is_password=self.hide_user_input_in_prompt, - confirm=False, - prefill=prefill, - is_multiline=(self.type == "text"), - autocomplete=self.choices or [], - help=_value_for_locale(self.help), - ) - - def _format_text_for_user_input_in_cli(self): - text_for_user_input_in_cli = _value_for_locale(self.ask) + def _get_prompt_message(self) -> str: + message = super()._get_prompt_message() if self.readonly: - text_for_user_input_in_cli = colorize(text_for_user_input_in_cli, "purple") - if self.choices: - return ( - text_for_user_input_in_cli + f" {self.choices[self.current_value]}" - ) - return text_for_user_input_in_cli + f" {self.humanize(self.current_value)}" - elif self.choices: - # Prevent displaying a shitload of choices - # (e.g. 100+ available users when choosing an app admin...) - choices = ( - list(self.choices.keys()) - if isinstance(self.choices, dict) - else self.choices - ) - choices_to_display = choices[:20] - remaining_choices = len(choices[20:]) + message = colorize(message, "purple") + return f"{message} {self.humanize(self.current_value)}" - if remaining_choices > 0: - choices_to_display += [ - m18n.n("other_available_options", n=remaining_choices) - ] - - choices_to_display = " | ".join(choices_to_display) - - text_for_user_input_in_cli += f" [{choices_to_display}]" - - return text_for_user_input_in_cli + return message def _value_pre_validator(self): if self.value in [None, ""] and not self.optional: - raise YunohostValidationError("app_argument_required", name=self.name) + raise YunohostValidationError("app_argument_required", name=self.id) # we have an answer, do some post checks if self.value not in [None, ""]: - if self.choices and self.value not in self.choices: - raise YunohostValidationError( - "app_argument_choice_invalid", - name=self.name, - value=self.value, - choices=", ".join(str(choice) for choice in self.choices), - ) if self.pattern and not re.match(self.pattern["regexp"], str(self.value)): raise YunohostValidationError( self.pattern["error"], - name=self.name, + name=self.id, value=self.value, ) @@ -387,79 +414,33 @@ class BaseOption: return self.value -# ╭───────────────────────────────────────────────────────╮ -# │ DISPLAY OPTIONS │ -# ╰───────────────────────────────────────────────────────╯ - - -class DisplayTextOption(BaseOption): - argument_type = "display_text" - - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): - super().__init__(question, context, hooks) - - self.optional = True - self.readonly = True - self.style = question.get( - "style", "info" if question["type"] == "alert" else "" - ) - - def _format_text_for_user_input_in_cli(self): - text = _value_for_locale(self.ask) - - if self.style in ["success", "info", "warning", "danger"]: - color = { - "success": "green", - "info": "cyan", - "warning": "yellow", - "danger": "red", - } - prompt = m18n.g(self.style) if self.style != "danger" else m18n.n("danger") - return colorize(prompt, color[self.style]) + f" {text}" - else: - return text - - -class ButtonOption(BaseOption): - argument_type = "button" - enabled = None - - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): - super().__init__(question, context, hooks) - self.enabled = question.get("enabled", None) - - -# ╭───────────────────────────────────────────────────────╮ -# │ INPUT OPTIONS │ -# ╰───────────────────────────────────────────────────────╯ - - # ─ STRINGS ─────────────────────────────────────────────── -class StringOption(BaseOption): - argument_type = "string" +class BaseStringOption(BaseInputOption): default_value = "" -class PasswordOption(BaseOption): +class StringOption(BaseStringOption): + type: Literal[OptionType.string] = OptionType.string + + +class TextOption(BaseStringOption): + type: Literal[OptionType.text] = OptionType.text + + +class PasswordOption(BaseInputOption): + type: Literal[OptionType.password] = OptionType.password hide_user_input_in_prompt = True - argument_type = "password" default_value = "" forbidden_chars = "{}" - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): - super().__init__(question, context, hooks) + def __init__(self, question): + super().__init__(question) self.redact = True if self.default is not None: raise YunohostValidationError( - "app_argument_password_no_default", name=self.name + "app_argument_password_no_default", name=self.id ) def _value_pre_validator(self): @@ -477,7 +458,8 @@ class PasswordOption(BaseOption): assert_password_is_strong_enough("user", self.value) -class ColorOption(StringOption): +class ColorOption(BaseStringOption): + type: Literal[OptionType.color] = OptionType.color pattern = { "regexp": r"^#[ABCDEFabcdef\d]{3,6}$", "error": "config_validate_color", # i18n: config_validate_color @@ -487,14 +469,12 @@ class ColorOption(StringOption): # ─ NUMERIC ─────────────────────────────────────────────── -class NumberOption(BaseOption): - argument_type = "number" +class NumberOption(BaseInputOption): + type: Literal[OptionType.number, OptionType.range] = OptionType.number default_value = None - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): - super().__init__(question, context, hooks) + def __init__(self, question): + super().__init__(question) self.min = question.get("min", None) self.max = question.get("max", None) self.step = question.get("step", None) @@ -516,7 +496,7 @@ class NumberOption(BaseOption): option = option.__dict__ if isinstance(option, BaseOption) else option raise YunohostValidationError( "app_argument_invalid", - name=option.get("name"), + name=option.get("id"), error=m18n.n("invalid_number"), ) @@ -528,14 +508,14 @@ class NumberOption(BaseOption): if self.min is not None and int(self.value) < self.min: raise YunohostValidationError( "app_argument_invalid", - name=self.name, + name=self.id, error=m18n.n("invalid_number_min", min=self.min), ) if self.max is not None and int(self.value) > self.max: raise YunohostValidationError( "app_argument_invalid", - name=self.name, + name=self.id, error=m18n.n("invalid_number_max", max=self.max), ) @@ -543,16 +523,14 @@ class NumberOption(BaseOption): # ─ BOOLEAN ─────────────────────────────────────────────── -class BooleanOption(BaseOption): - argument_type = "boolean" +class BooleanOption(BaseInputOption): + type: Literal[OptionType.boolean] = OptionType.boolean default_value = 0 yes_answers = ["1", "yes", "y", "true", "t", "on"] no_answers = ["0", "no", "n", "false", "f", "off"] - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): - super().__init__(question, context, hooks) + def __init__(self, question): + super().__init__(question) self.yes = question.get("yes", 1) self.no = question.get("no", 0) if self.default is None: @@ -576,7 +554,7 @@ class BooleanOption(BaseOption): raise YunohostValidationError( "app_argument_choice_invalid", - name=option.get("name"), + name=option.get("id"), value=value, choices="yes/no", ) @@ -616,7 +594,7 @@ class BooleanOption(BaseOption): raise YunohostValidationError( "app_argument_choice_invalid", - name=option.get("name"), + name=option.get("id"), value=strvalue, choices="yes/no", ) @@ -624,19 +602,20 @@ class BooleanOption(BaseOption): def get(self, key, default=None): return getattr(self, key, default) - def _format_text_for_user_input_in_cli(self): - text_for_user_input_in_cli = super()._format_text_for_user_input_in_cli() + def _get_prompt_message(self): + message = super()._get_prompt_message() if not self.readonly: - text_for_user_input_in_cli += " [yes | no]" + message += " [yes | no]" - return text_for_user_input_in_cli + return message # ─ TIME ────────────────────────────────────────────────── -class DateOption(StringOption): +class DateOption(BaseStringOption): + type: Literal[OptionType.date] = OptionType.date pattern = { "regexp": r"^\d{4}-\d\d-\d\d$", "error": "config_validate_date", # i18n: config_validate_date @@ -654,7 +633,8 @@ class DateOption(StringOption): raise YunohostValidationError("config_validate_date") -class TimeOption(StringOption): +class TimeOption(BaseStringOption): + type: Literal[OptionType.time] = OptionType.time pattern = { "regexp": r"^(?:\d|[01]\d|2[0-3]):[0-5]\d$", "error": "config_validate_time", # i18n: config_validate_time @@ -664,15 +644,16 @@ class TimeOption(StringOption): # ─ LOCATIONS ───────────────────────────────────────────── -class EmailOption(StringOption): +class EmailOption(BaseStringOption): + type: Literal[OptionType.email] = OptionType.email pattern = { "regexp": r"^.+@.+", "error": "config_validate_email", # i18n: config_validate_email } -class WebPathOption(BaseOption): - argument_type = "path" +class WebPathOption(BaseInputOption): + type: Literal[OptionType.path] = OptionType.path default_value = "" @staticmethod @@ -682,7 +663,7 @@ class WebPathOption(BaseOption): if not isinstance(value, str): raise YunohostValidationError( "app_argument_invalid", - name=option.get("name"), + name=option.get("id"), error="Argument for path should be a string.", ) @@ -695,14 +676,15 @@ class WebPathOption(BaseOption): elif option.get("optional") is False: raise YunohostValidationError( "app_argument_invalid", - name=option.get("name"), + name=option.get("id"), error="Option is mandatory", ) return "/" + value.strip().strip(" /") -class URLOption(StringOption): +class URLOption(BaseStringOption): + type: Literal[OptionType.url] = OptionType.url pattern = { "regexp": r"^https?://.*$", "error": "config_validate_url", # i18n: config_validate_url @@ -712,14 +694,12 @@ class URLOption(StringOption): # ─ FILE ────────────────────────────────────────────────── -class FileOption(BaseOption): - argument_type = "file" +class FileOption(BaseInputOption): + type: Literal[OptionType.file] = OptionType.file upload_dirs: List[str] = [] - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): - super().__init__(question, context, hooks) + def __init__(self, question): + super().__init__(question) self.accept = question.get("accept", "") @classmethod @@ -745,7 +725,7 @@ class FileOption(BaseOption): ): raise YunohostValidationError( "app_argument_invalid", - name=self.name, + name=self.id, error=m18n.n("file_does_not_exist", path=str(self.value)), ) @@ -760,7 +740,7 @@ class FileOption(BaseOption): FileOption.upload_dirs += [upload_dir] - logger.debug(f"Saving file {self.name} for file question into {file_path}") + logger.debug(f"Saving file {self.id} for file question into {file_path}") def is_file_path(s): return isinstance(s, str) and s.startswith("/") and os.path.exists(s) @@ -780,8 +760,72 @@ class FileOption(BaseOption): # ─ CHOICES ─────────────────────────────────────────────── -class TagsOption(BaseOption): - argument_type = "tags" +class BaseChoicesOption(BaseInputOption): + def __init__( + self, + question: Dict[str, Any], + ): + super().__init__(question) + # Don't restrict choices if there's none specified + self.choices = question.get("choices", None) + + def _get_prompt_message(self) -> str: + message = super()._get_prompt_message() + + if self.readonly: + message = message + choice = self.current_value + + if isinstance(self.choices, dict) and choice is not None: + choice = self.choices[choice] + + return f"{colorize(message, 'purple')} {choice}" + + if self.choices: + # Prevent displaying a shitload of choices + # (e.g. 100+ available users when choosing an app admin...) + choices = ( + list(self.choices.keys()) + if isinstance(self.choices, dict) + else self.choices + ) + choices_to_display = choices[:20] + remaining_choices = len(choices[20:]) + + if remaining_choices > 0: + choices_to_display += [ + m18n.n("other_available_options", n=remaining_choices) + ] + + choices_to_display = " | ".join( + str(choice) for choice in choices_to_display + ) + + return f"{message} [{choices_to_display}]" + + return message + + def _value_pre_validator(self): + super()._value_pre_validator() + + # we have an answer, do some post checks + if self.value not in [None, ""]: + if self.choices and self.value not in self.choices: + raise YunohostValidationError( + "app_argument_choice_invalid", + name=self.id, + value=self.value, + choices=", ".join(str(choice) for choice in self.choices), + ) + + +class SelectOption(BaseChoicesOption): + type: Literal[OptionType.select] = OptionType.select + default_value = "" + + +class TagsOption(BaseChoicesOption): + type: Literal[OptionType.tags] = OptionType.tags default_value = "" @staticmethod @@ -809,13 +853,13 @@ class TagsOption(BaseOption): if self.choices: raise YunohostValidationError( "app_argument_choice_invalid", - name=self.name, + name=self.id, value=self.value, choices=", ".join(str(choice) for choice in self.choices), ) raise YunohostValidationError( "app_argument_invalid", - name=self.name, + name=self.id, error=f"'{str(self.value)}' is not a list", ) @@ -830,15 +874,16 @@ class TagsOption(BaseOption): return super()._value_post_validator() -class DomainOption(BaseOption): - argument_type = "domain" +# ─ ENTITIES ────────────────────────────────────────────── - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): + +class DomainOption(BaseChoicesOption): + type: Literal[OptionType.domain] = OptionType.domain + + def __init__(self, question): from yunohost.domain import domain_list, _get_maindomain - super().__init__(question, context, hooks) + super().__init__(question) if self.default is None: self.default = _get_maindomain() @@ -861,15 +906,14 @@ class DomainOption(BaseOption): return value -class AppOption(BaseOption): - argument_type = "app" +class AppOption(BaseChoicesOption): + type: Literal[OptionType.app] = OptionType.app - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): + def __init__(self, question): from yunohost.app import app_list - super().__init__(question, context, hooks) + super().__init__(question) + self.filter = question.get("filter", None) apps = app_list(full=True)["apps"] @@ -888,16 +932,14 @@ class AppOption(BaseOption): self.choices.update({app["id"]: _app_display(app) for app in apps}) -class UserOption(BaseOption): - argument_type = "user" +class UserOption(BaseChoicesOption): + type: Literal[OptionType.user] = OptionType.user - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): + def __init__(self, question): from yunohost.user import user_list, user_info from yunohost.domain import _get_maindomain - super().__init__(question, context, hooks) + super().__init__(question) self.choices = { username: f"{infos['fullname']} ({infos['mail']})" @@ -907,7 +949,7 @@ class UserOption(BaseOption): if not self.choices: raise YunohostValidationError( "app_argument_invalid", - name=self.name, + name=self.id, error="You should create a YunoHost user first.", ) @@ -921,15 +963,13 @@ class UserOption(BaseOption): break -class GroupOption(BaseOption): - argument_type = "group" +class GroupOption(BaseChoicesOption): + type: Literal[OptionType.group] = OptionType.group - def __init__( - self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {} - ): + def __init__(self, question): from yunohost.user import user_group_list - super().__init__(question, context) + super().__init__(question) self.choices = list( user_group_list(short=True, include_primary_groups=False)["groups"] @@ -948,32 +988,48 @@ class GroupOption(BaseOption): OPTIONS = { - "display_text": DisplayTextOption, - "markdown": DisplayTextOption, - "alert": DisplayTextOption, - "button": ButtonOption, - "string": StringOption, - "text": StringOption, - "password": PasswordOption, - "color": ColorOption, - "number": NumberOption, - "range": NumberOption, - "boolean": BooleanOption, - "date": DateOption, - "time": TimeOption, - "email": EmailOption, - "path": WebPathOption, - "url": URLOption, - "file": FileOption, - "select": StringOption, - "tags": TagsOption, - "domain": DomainOption, - "app": AppOption, - "user": UserOption, - "group": GroupOption, + OptionType.display_text: DisplayTextOption, + OptionType.markdown: MarkdownOption, + OptionType.alert: AlertOption, + OptionType.button: ButtonOption, + OptionType.string: StringOption, + OptionType.text: StringOption, + OptionType.password: PasswordOption, + OptionType.color: ColorOption, + OptionType.number: NumberOption, + OptionType.range: NumberOption, + OptionType.boolean: BooleanOption, + OptionType.date: DateOption, + OptionType.time: TimeOption, + OptionType.email: EmailOption, + OptionType.path: WebPathOption, + OptionType.url: URLOption, + OptionType.file: FileOption, + OptionType.select: SelectOption, + OptionType.tags: TagsOption, + OptionType.domain: DomainOption, + OptionType.app: AppOption, + OptionType.user: UserOption, + OptionType.group: GroupOption, } +def hydrate_option_type(raw_option: dict[str, Any]) -> dict[str, Any]: + type_ = raw_option.get( + "type", OptionType.select if "choices" in raw_option else OptionType.string + ) + # LEGACY (`choices` in option `string` used to be valid) + if "choices" in raw_option and type_ == OptionType.string: + logger.warning( + f"Packagers: option {raw_option['id']} has 'choices' but has type 'string', use 'select' instead to remove this warning." + ) + type_ = OptionType.select + + raw_option["type"] = type_ + + return raw_option + + # ╭───────────────────────────────────────────────────────╮ # │ ╷ ╷╶┬╴╶┬╴╷ ╭─╴ │ # │ │ │ │ │ │ ╰─╮ │ @@ -981,12 +1037,127 @@ OPTIONS = { # ╰───────────────────────────────────────────────────────╯ +Hooks = dict[str, Callable[[BaseInputOption], Any]] + + +def prompt_or_validate_form( + raw_options: dict[str, Any], + prefilled_answers: dict[str, Any] = {}, + context: Context = {}, + hooks: Hooks = {}, +) -> list[BaseOption]: + options = [] + answers = {**prefilled_answers} + + for id_, raw_option in raw_options.items(): + raw_option["id"] = id_ + raw_option["value"] = answers.get(id_) + raw_option = hydrate_option_type(raw_option) + option = OPTIONS[raw_option["type"]](raw_option) + + interactive = Moulinette.interface.type == "cli" and os.isatty(1) + + if isinstance(option, ButtonOption): + if option.is_visible(context) and option.is_enabled(context): + continue + else: + raise YunohostValidationError( + "config_action_disabled", + action=option.id, + help=_value_for_locale(option.help), + ) + + # FIXME not sure why we do not append Buttons to returned options + options.append(option) + + if not option.is_visible(context): + if isinstance(option, BaseInputOption): + # FIXME There could be several use case if the question is not displayed: + # - we doesn't want to give a specific value + # - we want to keep the previous value + # - we want the default value + option.value = context[option.id] = None + + continue + + message = option._get_prompt_message() + + if option.readonly: + if interactive: + Moulinette.display(message) + + if isinstance(option, BaseInputOption): + option.value = context[option.id] = option.current_value + + continue + + if isinstance(option, BaseInputOption): + for i in range(5): + if interactive and option.value is None: + prefill = "" + choices = ( + option.choices if isinstance(option, BaseChoicesOption) else [] + ) + + if option.current_value is not None: + prefill = option.humanize(option.current_value, option) + elif option.default is not None: + prefill = option.humanize(option.default, option) + + option.value = Moulinette.prompt( + message=message, + is_password=isinstance(option, PasswordOption), + confirm=False, + prefill=prefill, + is_multiline=(option.type == "text"), + autocomplete=choices, + help=_value_for_locale(option.help), + ) + + # Apply default value + class_default = getattr(option, "default_value", None) + if option.value in [None, ""] and ( + option.default is not None or class_default is not None + ): + option.value = ( + class_default if option.default is None else option.default + ) + + try: + # Normalize and validate + option.value = option.normalize(option.value, option) + option._value_pre_validator() + except YunohostValidationError as e: + # If in interactive cli, re-ask the current question + if i < 4 and interactive: + logger.error(str(e)) + option.value = None + continue + + # Otherwise raise the ValidationError + raise + + break + + option.value = option.values[option.id] = option._value_post_validator() + + # Search for post actions in hooks + post_hook = f"post_ask__{option.id}" + if post_hook in hooks: + option.values.update(hooks[post_hook](option)) + + answers.update(option.values) + context.update(option.values) + + return options + + def ask_questions_and_parse_answers( - raw_questions: Dict, + raw_options: dict[str, Any], prefilled_answers: Union[str, Mapping[str, Any]] = {}, current_values: Mapping[str, Any] = {}, - hooks: Dict[str, Callable[[], None]] = {}, -) -> List[BaseOption]: + hooks: Hooks = {}, +) -> list[BaseOption]: """Parse arguments store in either manifest.json or actions.json or from a config panel against the user answers when they are present. @@ -1013,39 +1184,19 @@ def ask_questions_and_parse_answers( answers = {} context = {**current_values, **answers} - out = [] - for name, raw_question in raw_questions.items(): - raw_question["name"] = name - question_class = OPTIONS[raw_question.get("type", "string")] - raw_question["value"] = answers.get(name) - question = question_class(raw_question, context=context, hooks=hooks) - if question.type == "button": - if question.enabled is None or evaluate_simple_js_expression( # type: ignore - question.enabled, context=context # type: ignore - ): # type: ignore - continue - else: - raise YunohostValidationError( - "config_action_disabled", - action=question.name, - help=_value_for_locale(question.help), - ) - - new_values = question.ask_if_needed() - answers.update(new_values) - context.update(new_values) - out.append(question) - - return out + return prompt_or_validate_form( + raw_options, prefilled_answers=answers, context=context, hooks=hooks + ) def hydrate_questions_with_choices(raw_questions: List) -> List: out = [] for raw_question in raw_questions: - question = OPTIONS[raw_question.get("type", "string")](raw_question) - if question.choices: + raw_question = hydrate_option_type(raw_question) + question = OPTIONS[raw_question["type"]](raw_question) + if isinstance(question, BaseChoicesOption) and question.choices: raw_question["choices"] = question.choices raw_question["default"] = question.default out.append(raw_question)