diff --git a/src/utils/form.py b/src/utils/form.py new file mode 100644 index 000000000..55bbfc6ca --- /dev/null +++ b/src/utils/form.py @@ -0,0 +1,1297 @@ +# +# Copyright (c) 2023 YunoHost Contributors +# +# This file is part of YunoHost (see https://yunohost.org) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +import ast +import datetime +import operator as op +import re +import urllib +from enum import Enum +from pathlib import Path +from types import new_class +from typing import Annotated, Any, Literal, Type, TypeVar, Union, get_args, cast + +import pydantic +from pydantic import BaseModel, root_validator, validator +from pydantic.fields import Field, FieldInfo + +from moulinette import Moulinette, m18n +from moulinette.interfaces.cli import colorize +from moulinette.utils.filesystem import read_yaml +from moulinette.utils.log import getActionLogger +from yunohost.utils.error import YunohostError, YunohostValidationError +from yunohost.utils.i18n import _value_for_locale + +# Use a more generic logger name? +logger = getActionLogger("yunohost.config") + + +# ╭───────────────────────────────────────────────────────╮ +# │ ╭─╴╷ ╷╭─╴╶┬╴╭─╮╭╮╮ ╶┬╴╷ ╷┌─╮┌─╴╭─╴ │ +# │ │ │ │╰─╮ │ │ ││││ │ ╰─┤├─╯├─╴╰─╮ │ +# │ ╰─╴╰─╯╶─╯ ╵ ╰─╯╵╵╵ ╵ ╶─╯╵ ╰─╴╶─╯ │ +# ╰───────────────────────────────────────────────────────╯ + +T = TypeVar("T") + + +class ConstrainedComaList(pydantic.ConstrainedList[T]): # type: ignore + """ + Special type to handle bash style list parsing + """ + + @classmethod + def __get_validators__(cls): + # First parse possible bash style list `"item,item"` -> `["item", "item"]` + yield cls.parse_bash_style_list + # yield pydantic validators (parsing items to given `item_type`, regex validations, etc.) + yield from super().__get_validators__() + + @classmethod + def parse_bash_style_list( + cls, v: Union[list[T], str, None] + ) -> Union[list[T], list[str], None]: + if v is None or v == "": + # FIXME parse `""` as `[]` instead? + return None + + if isinstance(v, str): + values = [value.strip() for value in v.split(",")] + return [value for value in values if value] + + return v + + +def concomalist( + item_type: Type[T], + *, + min_items: Union[int, None] = None, + max_items: Union[int, None] = None, + unique_items: Union[bool, None] = None, +) -> Type[list[T]]: + """ + Special factory (copypasta of pydantic.conlist) to create a constrained + list class that handle our custom bash style list parsing. + """ + # __args__ is needed to conform to typing generics api + namespace = dict( + min_items=min_items, + max_items=max_items, + unique_items=unique_items, + item_type=item_type, + __args__=(item_type,), + ) + # We use new_class to be able to deal with Generic types + return new_class( + "ConstrainedComaListValue", + (ConstrainedComaList,), + {}, + lambda ns: ns.update(namespace), + ) + + +# ╭───────────────────────────────────────────────────────╮ +# │ ╭─╮┌─╮╶┬╴╶┬╴╭─╮╭╮╷╭─╴ │ +# │ │ │├─╯ │ │ │ ││││╰─╮ │ +# │ ╰─╯╵ ╵ ╶┴╴╰─╯╵╰╯╶─╯ │ +# ╰───────────────────────────────────────────────────────╯ + + +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" + # choice + select = "select" + tags = "tags" + # file + file = "file" + # entity + app = "app" + domain = "domain" + user = "user" + group = "group" + + +Translation = Union[dict[str, str], str, None] +Style = Literal["success", "info", "warning", "danger"] + + +class Pattern(BaseModel): + regexp: str + error: Translation = "error_pattern" + + +def enum_to_cls(enum_value: OptionType): + """ + Return the corresponding: option type enum value -> class definition + """ + # FIXME probably better way to get type class + _, types = pydantic.utils.get_discriminator_alias_and_values( + AnyOption, discriminator_key="type" + ) + return get_args(AnyOption)[types.index(OptionType[enum_value])] + + +class BaseOption(BaseModel): + """ + BaseOption is the base model for any config panel or manifest install + dynamic form 'option'. + Any parsing or validation defined on this model or subclasses are to + validate and interprete what devs/packagers writes, not what a user could + provide for this option. + + The parsing/validation of user inputs are built upon options as another + dynamic model created by `build_form`. + """ + + type: OptionType + ask: Translation + name: Union[str, None] = None # FIXME name or id? + id: str + bind: Union[str, None] = None + readonly: bool = False + visible: Union[str, bool] = True + + class Config: + arbitrary_types_allowed = True + use_enum_values = True + validate_assignment = True + + @staticmethod + def schema_extra(schema: dict[str, Any], model: type["BaseOption"]) -> None: + # FIXME Do proper doctstring for Options + del schema["description"] + schema["additionalProperties"] = False + + def is_visible(self, context: dict[str, Any]): + if isinstance(self.visible, bool): + return self.visible + + return evaluate_simple_js_expression(self.visible, context=context) + + @root_validator(pre=True) + def warn_unsupported_keys(cls, values): + extras = set(values.keys()) - set(cls.__fields__.keys()) + if extras: + for extra in extras: + logger.warning( + f"Unknown key '{extra}' found in option '{values['id']}'" + ) + return values + + @validator("id") + def validate_id(cls, v, values): + forbidden_keywords = [ + "old", + "app", + "changed", + "file_hash", + "binds", + "types", + "formats", + "getter", + "setter", + "short_setting", + "type", + "bind", + "nothing_changed", + "changes_validated", + "result", + "max_progression", + ] + + if v in forbidden_keywords: + raise YunohostError("config_forbidden_keyword", keyword=values["id"]) + + return v + + @validator("readonly") + def allowed_as_readonly(cls, v, values): + forbidden_readonly_types = [ + OptionType.password, + # FIXME not sure why those are not allowed but maybe I misunderstood what `readonly` originally means. + OptionType.file, + OptionType.app, + OptionType.domain, + OptionType.user, + OptionType.group, + ] + + if v and values["type"] in forbidden_readonly_types: + raise YunohostError( + "config_forbidden_readonly_type", + type=values["type"], + id=values["id"], + ) + + return v + + +# ╭───────────────────────────────────────────────────────╮ +# │ DISPLAY OPTIONS │ +# ╰───────────────────────────────────────────────────────╯ + + +class BaseReadonlyOption(BaseOption): + _type = str + readonly: bool = True + + @validator("readonly") + def force(cls, v): + if v is False: + logger.warning( + "Packagers: `readonly` can't be `False` for option of type `{cls.type}`, forcing to `True`" + ) + return True + + +class DisplayTextOption(BaseReadonlyOption): + type: Literal[OptionType.display_text] + + +class MarkdownOption(BaseReadonlyOption): + type: Literal[OptionType.markdown] + + +class BaseStyledReadonlyOption(BaseReadonlyOption): + style: Union[Style, None] = None + icon: Union[str, None] = None + + +class AlertOption(BaseStyledReadonlyOption): + type: Literal[OptionType.alert] + style: Style = "info" + + +class ButtonOption(BaseStyledReadonlyOption): + type: Literal[OptionType.button] + style: Style = "success" + enabled: Union[str, bool] = True + # confirm: bool = False # TODO: to ask confirmation before running an action + + def is_enabled(self, context: dict[str, Any]): + if isinstance(self.enabled, bool): + return self.enabled + + return evaluate_simple_js_expression(self.enabled, context=context) + + +# ╭───────────────────────────────────────────────────────╮ +# │ INPUT OPTIONS │ +# ╰───────────────────────────────────────────────────────╯ +InputOptions = Literal[ + OptionType.string, + OptionType.text, + OptionType.color, + OptionType.password, + OptionType.number, + OptionType.range, + OptionType.boolean, + OptionType.date, + OptionType.time, + OptionType.email, + OptionType.path, + OptionType.url, + OptionType.select, + OptionType.tags, + OptionType.file, + OptionType.app, + OptionType.domain, + OptionType.user, + OptionType.group, +] + + +class BaseInputOption(BaseOption): + _anno: Any + pattern: Union[Pattern, None] = None + limit: Union[int, None] = None # FIXME move + optional: bool = False # FIXME keep required as default? + placeholder: Union[str, None] = None + help: Translation = None + example: Union[str, None] = None + redact: bool = False + default: Any + + @property + def _dynamic_annotation(self): + return self._anno + + def get_field_attrs(self) -> dict[str, Any]: + attrs: dict[str, Any] = {} + + if self.readonly: + attrs["allow_mutation"] = False + + if self.example: + attrs["examples"] = [self.example] + + if self.default is not None: + attrs["default"] = self.default + elif not self.optional: + attrs["default"] = ... + else: + attrs["default"] = None + + return attrs + + def as_dynamic_model_field( + self, + ) -> tuple[Union[object, Any], FieldInfo]: + attrs = self.get_field_attrs() + anno = ( + self._dynamic_annotation + if not self.optional + else Union[self._dynamic_annotation, None] + ) + field = Field(default=attrs.pop("default", None), **attrs) + + return (anno, field) + + @validator("readonly", pre=True) + def can_be_readonly(cls, v, values): + forbidden_types = ("password", "app", "domain", "user", "file") + if v is True and values["type"] in forbidden_types: + raise ValueError( + m18n.n( + "config_forbidden_readonly_type", + type=values["type"], + id=values["id"], + ) + ) + return v + + +# ─ STRINGS ─────────────────────────────────────────────── + + +class BaseStringOption(BaseInputOption): + _anno = str + default: Union[str, None] = "" + + @property + def _dynamic_annotation(self): + if self.pattern: + return pydantic.constr(regex=self.pattern.regexp) + return self._anno + + def get_field_attrs(self): + attrs = super().get_field_attrs() + if self.pattern: + attrs["regex_error"] = self.pattern.error # Extra + return attrs + + +class StringOption(BaseStringOption): + type: Literal[OptionType.string] + + +class TextOption(BaseStringOption): + type: Literal[OptionType.text] + + +class PasswordOption(BaseInputOption): + type: Literal[OptionType.password] + _anno = pydantic.SecretStr + default: None = None + redact: bool = True + # Unique + forbidden_chars: str = "{}" # FIXME add custom validator + + @root_validator(pre=True) + def force(cls, values): + values["default"] = None + values["redact"] = True + return values + + +class ColorOption(BaseInputOption): + type: Literal[OptionType.color] + _anno = pydantic.color.Color + default: Union[pydantic.color.Color, None] + + +# ─ NUMERIC ─────────────────────────────────────────────── + + +class BaseNumberOption(BaseInputOption): + _anno = Union[int, float] + default: Union[int, float, None] + + min: Union[int, None] = None + max: Union[int, None] = None + step: Union[int, None] = None + + def get_field_attrs(self): + attrs = super().get_field_attrs() + attrs["ge"] = self.min + attrs["le"] = self.max + attrs["step"] = self.step # Extra + + return attrs + + +class NumberOption(BaseNumberOption): + type: Literal[OptionType.number] + + +class RangeOption(BaseNumberOption): + type: Literal[OptionType.range] + + +# ─ BOOLEAN ─────────────────────────────────────────────── + + +class BooleanOption(BaseInputOption): + type: Literal[OptionType.boolean] + _anno = bool + default: Union[bool, None] = False + yes: Any = "1" + no: Any = "0" + _possible_cli_yes: set[str] = {"0", "no", "n", "false", "f", "off"} + _possible_cli_no: set[str] = {"0", "no", "n", "false", "f", "off"} + + def get_field_attrs(self): + attrs = super().get_field_attrs() + attrs["parse"] = { # Extra + "yes": self.yes, + "no": self.no, + # "possible_yes": self._possible_cli_yes, + # "possible_no": self._possible_cli_no, + } + return attrs + + @validator("yes", pre=True) + def yes_is_not_falsy_value(cls, v): + if str(v).lower() in cls._possible_cli_no: + raise ValueError(f"'yes' value can't be in {cls._possible_cli_no}") + return v + + @validator("no", pre=True) + def no_is_not_truthy_value(cls, v): + if str(v).lower() in cls._possible_cli_yes: + raise ValueError(f"'no' value can't be in {cls._possible_cli_yes}") + return v + + +# ─ TIME ────────────────────────────────────────────────── +class DateOption(BaseInputOption): + type: Literal[OptionType.date] + _anno = datetime.date + default: Union[datetime.date, None] + + +class TimeOption(BaseInputOption): + type: Literal[OptionType.time] + _anno = datetime.time + default: Union[datetime.time, None] + + +# ─ LOCATIONS ───────────────────────────────────────────── + + +class EmailOption(BaseInputOption): + type: Literal[OptionType.email] + _anno = pydantic.EmailStr + default: Union[pydantic.EmailStr, None] + + +class PathOption(BaseInputOption): + type: Literal[OptionType.path] + _anno = Path + default: Union[Path, None] + + +class UrlOption(BaseInputOption): + type: Literal[OptionType.url] + _anno = pydantic.HttpUrl + default: Union[pydantic.HttpUrl, None] + + +# ─ CHOICES ─────────────────────────────────────────────── + +ChoosableOptions = Literal[ + OptionType.string, + OptionType.color, + OptionType.number, + OptionType.date, + OptionType.time, + OptionType.email, + OptionType.path, + OptionType.url, + # OptionType.file, + # OptionType.app, + # OptionType.domain, + # OptionType.user, + # OptionType.group, +] + + +class BaseChoicesOption(BaseInputOption): + _anno = Any + item_type: ChoosableOptions = OptionType.string + choices: Union[dict[str, Any], list[Any], None] + default: Any = None + + @property + def _dynamic_annotation(self): + annotation = enum_to_cls(self.item_type)._anno + # Repeat pattern stuff since we can't call the bare class `_dynamic_annotation` prop without instantiating it + if annotation is str and self.pattern: + annotation = pydantic.constr(regex=self.pattern.regexp) + + if self.choices is not None: + choices = ( + self.choices if isinstance(self.choices, list) else self.choices.keys() + ) + annotation = Literal[tuple(choices)] + + return annotation + + @root_validator() + def validate_default_in_choices(cls, values): + default = values.get("default", None) + choices = values.get("choices", None) + + if not default or not choices: + return values + + choices = choices.keys() if isinstance(choices, dict) else choices + + if default not in choices: + raise ValueError(f"default value '{default}' is not a valid choice.") + + return values + + +class SelectOption(BaseChoicesOption): + type: Literal[OptionType.select] + choices: Union[dict[str, Any], list[Any]] + default: Union[str, None] = None + + +class TagsOption(BaseChoicesOption): + type: Literal[OptionType.tags] + choices: Union[list[Any], None] = None + default: Union[ConstrainedComaList[str], list, None] = None + icon: Union[str, None] = None + + @property + def _dynamic_annotation(self): + return concomalist(super()._dynamic_annotation) + + +# ─ FILE ────────────────────────────────────────────────── + + +class FileOption(BaseInputOption): + type: Literal[OptionType.file] + # `FilePath` for CLI (file must be a file and must exists) + # `str` for API for now since moulinette doesn't handle files + _anno = Union[str, pydantic.FilePath] + # Unique + accept: Union[str, None] = None + + +# ─ ENTITIES ────────────────────────────────────────────── + + +class AppOption(BaseChoicesOption): + type: Literal[OptionType.app] + _anno = str + # Unique + filter: Union[str, bool, None] = None + + @root_validator(pre=True) + def inject_apps_as_choices(cls, values): + return values + from yunohost.app import app_list + + apps = app_list(full=True)["apps"] + + if values.get("filter"): + apps = [ + app + for app in apps + if evaluate_simple_js_expression(values["filter"], context=app) + ] + + values["choices"] = { + app["id"]: f"{app['label']} ({app.get('domain_path', app['id'])})" + for app in apps + } + + return values + + +class DomainOption(BaseChoicesOption): + type: Literal[OptionType.domain] + _anno = str + # Unique + # filter: Union[str, None] = None + + @root_validator() + def inject_domains_as_choices(cls, values): + # FIXME remove calls to resources in validators (pydantic V2 should adress this) + from yunohost.domain import domain_list + + domain_list = domain_list() + main_domain = domain_list["main"] + + # if values.get("filter"): + + values["choices"] = { + domain: domain + " ★" if domain == main_domain else domain + for domain in domain_list["domains"] + } + + if not values["optional"] and values["default"] is None: + values["default"] = main_domain + + return values + + +class UserOption(BaseChoicesOption): + type: Literal[OptionType.user] + _anno = str + # Unique + # filter: Union[str, None] = None + + @root_validator() + def inject_users_as_choices(cls, values): + from yunohost.user import user_list + + # FIXME remove calls to resources in validators (pydantic V2 should adress this) + users = user_list(["username", "fullname", "mail", "groups"])["users"].items() + # if values.get("filter"): + + values["choices"] = { + username: f"{infos['fullname']} ({infos['mail']})" + + (" ★" if "admins" in infos["groups"] else "") + for username, infos in users + } + + if not values["choices"]: + # FIXME Is this the right place? + raise YunohostValidationError( + "app_argument_invalid", + name=values["id"], + error="You should create a YunoHost user first.", + ) + if not values["optional"] and values["default"] is None: + values["default"] = next( + username for username, infos in users if "admins" in infos["groups"] + ) + + return values + + +class GroupOption(BaseChoicesOption): + type: Literal[OptionType.group] + _anno = str + # Unique + # filter: Union[str, None] = None + + @root_validator() + def inject_groups_as_choices(cls, values): + # TODO remove calls to resources in validators (pydantic V2 should adress this) + from yunohost.user import user_group_list + + def _human_readable_group(groupname): + # i18n: visitors + # i18n: all_users + # i18n: admins + return ( + m18n.n(groupname) + if groupname in ["visitors", "all_users", "admins"] + else groupname + ) + + # TODO could allow filter (without primary groups for example) + # if values.get("filter"): + + values["choices"] = { + groupname: _human_readable_group(groupname) + for groupname in user_group_list(short=True, include_primary_groups=False)[ + "groups" + ] + } + + if not values["optional"] and values["default"] is None: + values["default"] = "all_users" + + return values + + +AnyOption = Union[ + # display + DisplayTextOption, + MarkdownOption, + AlertOption, + # action + ButtonOption, + # text + StringOption, + TextOption, + PasswordOption, + ColorOption, + # number + NumberOption, + RangeOption, + # boolean + BooleanOption, + # time + DateOption, + TimeOption, + # location + EmailOption, + PathOption, + UrlOption, + # choice + SelectOption, + TagsOption, + # file + FileOption, + # entity + AppOption, + DomainOption, + UserOption, + GroupOption, +] + + +# ╭───────────────────────────────────────────────────────╮ +# │ ┌─╴╭─╮┌─╮╭╮╮ │ +# │ ├─╴│ │├┬╯│││ │ +# │ ╵ ╰─╯╵ ╰╵╵╵ │ +# ╰───────────────────────────────────────────────────────╯ + + +class OptionsContainer(BaseModel): + # Pydantic will match option types to their models class based on the "type" attribute + options: list[Annotated[AnyOption, Field(discriminator="type")]] + + class Config: + extra = pydantic.Extra.allow + + @staticmethod + def options_dict_to_list(options: dict[str, Any], defaults: dict[str, Any] = {}): + return [ + data + | { + "id": name, + "type": data.get("type", "string"), + } + for name, data in options.items() + ] + + def __init__(self, **kwargs) -> None: + super().__init__(options=self.options_dict_to_list(kwargs)) + + def translate_options(self, i18n_key: Union[str, None] = None): + """ + Mutate in place translatable attributes of options to their translations + """ + for option in self.options: + for key in ("ask", "help"): + if not hasattr(option, key): + continue + + value = getattr(option, key) + if value: + setattr(option, key, _value_for_locale(value)) + elif key == "ask" and m18n.key_exists(f"{i18n_key}_{option.id}"): + setattr(option, key, m18n.n(f"{i18n_key}_{option.id}")) + elif key == "help" and m18n.key_exists(f"{i18n_key}_{option.id}_help"): + setattr(option, key, m18n.n(f"{i18n_key}_{option.id}_help")) + elif key == "ask": + # FIXME raise error? + option.ask = option.id + + +class YunoForm(BaseModel): + """ + Base form on which dynamic forms are built upon Options. + """ + + def __getitem__(self, name: str): + return getattr(self, name) + + def __setitem__(self, name: str, value: Any): + setattr(self, name, value) + + def get(self, name: str, default: Any = None) -> Any: + try: + return getattr(self, name) + except: + return default + + class Config: + validate_assignment = True + extra = pydantic.Extra.ignore + + def dict(self, *, as_env: bool = False, normalize: bool = True, **kwargs): + data = super().dict(**kwargs) + if as_env: + return {k: self.bashify(k) for k in data} + if normalize: + return {k: self.normalize(k) for k in data} + return data + + def normalize(self, option_id: str) -> str: + """ + Return a value as Python default types. + """ + v = self[option_id] + if isinstance(v, pydantic.SecretStr): + return v.get_secret_value() + elif isinstance(v, pydantic.color.Color): + return v.as_hex() + elif isinstance(v, Path): + return str(v) + else: + return v + + def bashify(self, option_id: str) -> str: + """ + Stringify a value for bash environment + """ + v = self[option_id] + if isinstance(v, bool): + extra = self.__fields__[option_id].field_info.extra + return extra["parse"]["yes" if v is True else "no"] + elif not v: + return "" + elif isinstance(v, pydantic.SecretStr): + return v.get_secret_value() + elif isinstance(v, pydantic.color.Color): + return v.as_hex() + elif isinstance(v, list): + return ",".join(v) + else: + return str(v) + + def humanize(self, option_id: str) -> str: + """ + Stringify a value to be used in cli prompt + """ + v = self[option_id] + if isinstance(v, bool): + return "yes" if v is True else "no" + elif isinstance(v, pydantic.color.Color): + return v.as_named() + else: + return self.bashify(option_id) + + @validator("*", pre=True) + def parse_string(cls, v): + """ + Global validator to clean strings and parse None from CLI + """ + # FIXME maybe do not parse `""` as None + if isinstance(v, str): + v = v.strip() + if v in ("", "null", "none", "_none"): + return None + return v + + +def parse_prefilled_values( + args: Union[str, None] = None, + args_file=None, # FIXME TYPING +) -> dict[str, Any]: + """ + Retrieve form values from yaml file or query string. + """ + values: dict[str, Any] = {} + if args_file: + # Import YAML / JSON file + values |= read_yaml(args_file) + if args: + values |= { + k: ",".join(v) + for k, v in urllib.parse.parse_qs(args, keep_blank_values=True).items() + } + return values + + +def build_form( + options: list[AnyOption], name: str = "DynamicYunoForm" +) -> Type[YunoForm]: + """ + Returns a dynamic pydantic model class that can act as a form with validation when instanciated. + """ + options_as_fields: Any = { + option.id: option.as_dynamic_model_field() + for option in options + if isinstance(option, BaseInputOption) # filter out non input options + } + return pydantic.create_model( + name, + __base__=YunoForm, + **options_as_fields, + ) + + +def fill_form( + options: list[AnyOption], + form: "YunoForm", + prefilled_answers: dict[str, Any], + context: dict[str, Any] = {}, + action_id: Union[str, None] = None, +) -> "YunoForm": + """ + API only method to validate a form passed as query string. + Most checks should be handled by the webadmin but we recheck in case of + direct call to API or webadmin missing some validation. + """ + logger.debug("Validating form...") + + for option in options: + if isinstance(option, ButtonOption): + if action_id == option.id: + if option.is_visible(context) and option.is_enabled(context): + # Action can be ran, quit prompt mode and return current form + return form + else: + # TODO provide an meaningfull error within option declaration? + raise YunohostValidationError( + f"Action '{action_id}' couldn't be ran, its conditions are not fullfilled." + ) + + prefilled = option.id in prefilled_answers + if not option.is_visible(context): + if prefilled: + logger.warning( + f"Skipping setting: '{option.id}' since conditions are not fullfilled." + ) + continue + + prev_value = form[option.id] + try: + form[option.id] = ( + prefilled_answers[option.id] if prefilled else form[option.id] + ) + context[option.id] = form.normalize(option.id) + if form[option.id] == prev_value: + form.__fields_set__.remove(option.id) + except pydantic.ValidationError as e: + # TODO could test every form values and return all errors directly instead of the first one + errors = "\n".join([error["msg"] for error in e.errors()]) + raise YunohostValidationError(errors, raw_msg=True) + + return form + + +def prompt_form( + options: list[AnyOption], + form: "YunoForm", + prefilled_answers: dict[str, Any], + context: dict[str, Any] = {}, + action_id: Union[str, None] = None, +) -> "YunoForm": + """ + CLI only method to interactively prompt and validate a form an option at a time. + """ + for option in options: + if isinstance(option, ButtonOption): + if option.id != action_id: + # Skip other buttons/actions that may be defined in the same action section + continue + if option.is_visible(context) and option.is_enabled(context): + # Action can be ran, quit prompt mode and return current form + return form + else: + # TODO provide an meaningfull error within option toml declaration? + raise YunohostValidationError( + f"Action '{action_id}' couldn't be ran, its conditions are not fullfilled." + ) + + if not option.is_visible(context): + if not isinstance(option, BaseReadonlyOption): + context[option.id] = form.normalize(option.id) + if option.id in prefilled_answers: + logger.warning( + f"Skipping setting: '{option.id}' since conditions are not fullfilled." + ) + continue + + # Cast option.ask to str since it should be translated + message = cast(str, option.ask) + + if isinstance(option, BaseReadonlyOption): + if isinstance(option, AlertOption): + colors = { + "success": "green", + "info": "cyan", + "warning": "yellow", + "danger": "red", + } + title = ( + m18n.g(option.style) + if option.style != "danger" + else m18n.n("danger") + ) + message = f"{colorize(title, colors[option.style])} {message}" + Moulinette.display(message) + continue + + prefill = form.humanize(option.id) + if option.readonly: + # FIXME a bit annoying to parse the value + # Parse the readonly value in case it come from bash then populate the context + form[option.id] = prefill + context[option.id] = form.normalize(option.id) + message = f"{colorize(message, 'purple')} {prefill}" + Moulinette.display(message) + continue + + choices = [] + if isinstance(option, BaseChoicesOption) and option.choices: + choices = ( + list(option.choices.keys()) + if isinstance(option.choices, dict) + else option.choices + ) + # Prevent displaying a shitload of choices + # (e.g. 100+ available users when choosing an app admin...) + 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) + ] + + message += f" [{' | '.join(choices_to_display)}]" + + prev_value = form[option.id] + while True: + if option.id in prefilled_answers: + # value was given thru query string or file, test it as if it was prompted + value = prefilled_answers[option.id] + else: + value = Moulinette.prompt( + message=message, + is_password=option.redact, + confirm=False, + prefill=prefill, + # default=option.humanize(option.default), + is_multiline=(option.type == "text"), + autocomplete=choices, + help=option.help, + ) + + try: + form[option.id] = value + context[option.id] = form.normalize(option.id) + + if form[option.id] == prev_value: + form.__fields_set__.remove(option.id) + + break + except pydantic.ValidationError as e: + if option.id in prefilled_answers: + Moulinette.display( + "Prefilled answer from args or file is not valid, you can correct it now:", + style="error", + ) + # Remove given prefilled_anwsers since its not valid. + # Will prompt for a correction + del prefilled_answers[option.id] + + for error in e.errors(): + # FIXME rework errors (use yunohost ones or translate pydantic ones) + Moulinette.display(error["msg"], style="error") + # Save erroneus answer and let ppl correct it + prefill = value + + # TODO hooks + # Question._post_parse_value (redac stuff in operation logger) + return form + + +# ╭───────────────────────────────────────────────────────╮ +# │ ┌─╴╷ ╷╭─┐╷ │ +# │ ├─╴│╭╯├─┤│ │ +# │ ╰─╴╰╯ ╵ ╵╰─╴ │ +# ╰───────────────────────────────────────────────────────╯ + +# Those js-like evaluate functions are used to eval safely `visible` and +# `enabled` attributes. +# The goal is to evaluate in the same way than js simple-evaluate +# https://github.com/shepherdwind/simple-evaluate +def evaluate_simple_ast(node, context=None): + if context is None: + context = {} + + operators = { + ast.Not: op.not_, + ast.Mult: op.mul, + ast.Div: op.truediv, # number + ast.Mod: op.mod, # number + ast.Add: op.add, # str + ast.Sub: op.sub, # number + ast.USub: op.neg, # Negative number + ast.Gt: op.gt, + ast.Lt: op.lt, + ast.GtE: op.ge, + ast.LtE: op.le, + ast.Eq: op.eq, + ast.NotEq: op.ne, + } + context["true"] = True + context["false"] = False + context["null"] = None + + # Variable + if isinstance(node, ast.Name): # Variable + return context[node.id] + + # Python <=3.7 String + elif isinstance(node, ast.Str): + return node.s + + # Python <=3.7 Number + elif isinstance(node, ast.Num): + return node.n + + # Boolean, None and Python 3.8 for Number, Boolean, String and None + elif isinstance(node, (ast.Constant, ast.NameConstant)): + return node.value + + # + - * / % + elif ( + isinstance(node, ast.BinOp) and type(node.op) in operators + ): # + left = evaluate_simple_ast(node.left, context) + right = evaluate_simple_ast(node.right, context) + if type(node.op) == ast.Add: + if isinstance(left, str) or isinstance(right, str): # support 'I am ' + 42 + left = str(left) + right = str(right) + elif type(left) != type(right): # support "111" - "1" -> 110 + left = float(left) + right = float(right) + + return operators[type(node.op)](left, right) + + # Comparison + # JS and Python don't give the same result for multi operators + # like True == 10 > 2. + elif ( + isinstance(node, ast.Compare) and len(node.comparators) == 1 + ): # + left = evaluate_simple_ast(node.left, context) + right = evaluate_simple_ast(node.comparators[0], context) + operator = node.ops[0] + if isinstance(left, (int, float)) or isinstance(right, (int, float)): + try: + left = float(left) + right = float(right) + except ValueError: + return type(operator) == ast.NotEq + try: + return operators[type(operator)](left, right) + except TypeError: # support "e" > 1 -> False like in JS + return False + + # and / or + elif isinstance(node, ast.BoolOp): # + for value in node.values: + value = evaluate_simple_ast(value, context) + if isinstance(node.op, ast.And) and not value: + return False + elif isinstance(node.op, ast.Or) and value: + return True + return isinstance(node.op, ast.And) + + # not / USub (it's negation number -\d) + elif isinstance(node, ast.UnaryOp): # e.g., -1 + return operators[type(node.op)](evaluate_simple_ast(node.operand, context)) + + # match function call + elif isinstance(node, ast.Call) and node.func.__dict__.get("id") == "match": + return re.match( + evaluate_simple_ast(node.args[1], context), context[node.args[0].id] + ) + + # Unauthorized opcode + else: + opcode = str(type(node)) + raise YunohostError( + f"Unauthorize opcode '{opcode}' in visible attribute", raw_msg=True + ) + + +def js_to_python(expr: str) -> str: + in_string = None + py_expr = "" + i = 0 + escaped = False + for char in expr: + if char in r"\"'": + # Start a string + if not in_string: + in_string = char + + # Finish a string + elif in_string == char and not escaped: + in_string = None + + # If we are not in a string, replace operators + elif not in_string: + if char == "!" and expr[i + 1] != "=": + char = "not " + elif char in "|&" and py_expr[-1:] == char: + py_expr = py_expr[:-1] + char = " and " if char == "&" else " or " + + # Determine if next loop will be in escaped mode + escaped = char == "\\" and not escaped + py_expr += char + i += 1 + return py_expr + + +def evaluate_simple_js_expression(expr: str, context: dict[str, Any] = {}) -> bool: + if not expr.strip(): + return False + node = ast.parse(js_to_python(expr), mode="eval").body + return evaluate_simple_ast(node, context)