# # 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 os import re import shutil import tempfile import urllib.parse from enum import Enum from logging import getLogger from typing import ( TYPE_CHECKING, cast, overload, Annotated, Any, Callable, Iterable, Literal, Mapping, Type, Union, ) from pydantic import ( BaseModel, Extra, ValidationError, create_model, validator, root_validator, ) from pydantic.color import Color from pydantic.fields import Field from pydantic.networks import EmailStr, HttpUrl from pydantic.types import constr from moulinette import Moulinette, m18n from moulinette.interfaces.cli import colorize from moulinette.utils.filesystem import read_yaml, write_to_file from yunohost.log import OperationLogger from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.i18n import _value_for_locale if TYPE_CHECKING: from pydantic.fields import ModelField, FieldInfo logger = getLogger("yunohost.form") # ╭───────────────────────────────────────────────────────╮ # │ ┌─╴╷ ╷╭─┐╷ │ # │ ├─╴│╭╯├─┤│ │ # │ ╰─╴╰╯ ╵ ╵╰─╴ │ # ╰───────────────────────────────────────────────────────╯ # Those js-like evaluate functions are used to eval safely visible 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) is ast.Add: if isinstance(left, str) or isinstance(right, str): # support 'I am ' + 42 left = str(left) right = str(right) elif type(left) is 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) is 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): 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) # ╭───────────────────────────────────────────────────────╮ # │ ╭─╮┌─╮╶┬╴╶┬╴╭─╮╭╮╷╭─╴ │ # │ │ │├─╯ │ │ │ ││││╰─╮ │ # │ ╰─╯╵ ╵ ╶┴╴╰─╯╵╰╯╶─╯ │ # ╰───────────────────────────────────────────────────────╯ 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" READONLY_TYPES = { OptionType.display_text, OptionType.markdown, OptionType.alert, OptionType.button, } FORBIDDEN_READONLY_TYPES = { OptionType.password, OptionType.app, OptionType.domain, OptionType.user, OptionType.group, } FORBIDDEN_KEYWORDS = { "old", "app", "changed", "file_hash", "binds", "types", "formats", "getter", "setter", "short_setting", "type", "bind", "nothing_changed", "changes_validated", "result", "max_progression", "properties", "defaults", } Context = dict[str, Any] Translation = Union[dict[str, str], str] JSExpression = str Values = dict[str, Any] Mode = Literal["python", "bash"] class Pattern(BaseModel): regexp: str error: Translation = "error_pattern" # FIXME add generic i18n key class BaseOption(BaseModel): """ Options are fields declaration that renders as form items, button, alert or text in the web-admin and printed or prompted in CLI. They are used in app manifests to declare the before installation form and in config panels. [Have a look at the app config panel doc](/packaging_apps_config_panels) for details about Panels and Sections. ! IMPORTANT: as for Panels and Sections you have to choose an id, but this one should be unique in all this document, even if the question is in an other panel. #### Example ```toml [section.my_option_id] type = "string" # ask as `str` ask = "The text in english" # ask as `dict` ask.en = "The text in english" ask.fr = "Le texte en français" # advanced props visible = "my_other_option_id != 'success'" readonly = true # much advanced: config panel only? bind = "null" ``` #### Properties - `type`: the actual type of the option, such as 'markdown', 'password', 'number', 'email', ... - `ask`: `Translation` (default to the option's `id` if not defined): - text to display as the option's label for inputs or text to display for readonly options - in config panels, questions are displayed on the left side and therefore have not much space to be rendered. Therefore, it is better to use a short question, and use the `help` property to provide additional details if necessary. - `visible` (optional): `bool` or `JSExpression` (default: `true`) - define if the option is diplayed/asked - if `false` and used alongside `readonly = true`, you get a context only value that can still be used in `JSExpression`s - `readonly` (optional): `bool` (default: `false`, forced to `true` for readonly types): - If `true` for input types: forbid mutation of its value - `bind` (optional): `Binding`, config panels only! A powerful feature that let you configure how and where the setting will be read, validated and written - if not specified, the value will be read/written in the app `settings.yml` - if `"null"`: - the value will not be stored at all (can still be used in context evaluations) - if in `scripts/config` there's a function named: - `get__my_option_id`: the value will be gathered from this custom getter - `set__my_option_id`: the value will be passed to this custom setter where you can do whatever you want with the value - `validate__my_option_id`: the value will be passed to this custom validator before any custom setter - if `bind` is a file path: - if the path starts with `:`, the value be saved as its id's variable/property counterpart - this only works for first level variables/properties and simple types (no array) - else the value will be stored as the whole content of the file - you can use `__FINALPATH__` or `__INSTALL_DIR__` in your path to point to dynamic install paths - FIXME are other global variables accessible? - [refer to `bind` doc for explaination and examples](#read-and-write-values-the) """ type: OptionType id: str mode: Mode = "bash" # TODO use "python" as default mode with AppConfigPanel setuping it to "bash" ask: Union[Translation, None] readonly: bool = False visible: Union[JSExpression, bool] = True bind: Union[str, None] = None name: Union[str, None] = None # LEGACY (replaced by `id`) class Config: arbitrary_types_allowed = True use_enum_values = True validate_assignment = True extra = Extra.forbid @staticmethod def schema_extra(schema: dict[str, Any]) -> None: del schema["properties"]["id"] del schema["properties"]["name"] schema["required"] = [ required for required in schema.get("required", []) if required != "id" ] if not schema["required"]: del schema["required"] @validator("id", pre=True) def check_id_is_not_forbidden(cls, value: str) -> str: if value in FORBIDDEN_KEYWORDS: raise ValueError(m18n.n("config_forbidden_keyword", keyword=value)) return value # FIXME Legacy, is `name` still needed? @validator("name") def apply_legacy_name(cls, value: Union[str, None], values: Values) -> str: if value is None: return values["id"] return value @validator("readonly", pre=True) def can_be_readonly(cls, value: bool, values: Values) -> bool: if value is True and values["type"] in FORBIDDEN_READONLY_TYPES: raise ValueError( m18n.n( "config_forbidden_readonly_type", type=values["type"], id=values["id"], ) ) return value 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, value: None) -> str: # force type to str # `OptionsModel.translate_options()` should have been called before calling this method return cast(str, self.ask) # ╭───────────────────────────────────────────────────────╮ # │ DISPLAY OPTIONS │ # ╰───────────────────────────────────────────────────────╯ class BaseReadonlyOption(BaseOption): readonly: Literal[True] = True class DisplayTextOption(BaseReadonlyOption): """ Display simple multi-line content. #### Example ```toml [section.my_option_id] type = "display_text" ask = "Simple text rendered as is." ``` """ type: Literal[OptionType.display_text] = OptionType.display_text class MarkdownOption(BaseReadonlyOption): """ Display markdown multi-line content. Markdown is currently only rendered in the web-admin #### Example ```toml [section.my_option_id] type = "display_text" ask = "Text **rendered** in markdown." ``` """ type: Literal[OptionType.markdown] = OptionType.markdown class State(str, Enum): success = "success" info = "info" warning = "warning" danger = "danger" class AlertOption(BaseReadonlyOption): """ Alerts displays a important message with a level of severity. You can use markdown in `ask` but will only be rendered in the web-admin. #### Example ```toml [section.my_option_id] type = "alert" ask = "The configuration seems to be manually modified..." style = "warning" icon = "warning" ``` #### Properties - [common properties](#common-properties) - `style`: any of `"success|info|warning|danger"` (default: `"info"`) - `icon` (optional): any icon name from [Fork Awesome](https://forkaweso.me/Fork-Awesome/icons/) - Currently only displayed in the web-admin """ type: Literal[OptionType.alert] = OptionType.alert style: State = State.info icon: Union[str, None] = None def _get_prompt_message(self, value: None) -> str: colors = { State.success: "green", State.info: "cyan", State.warning: "yellow", State.danger: "red", } message = m18n.g(self.style) if self.style != State.danger else m18n.n("danger") return f"{colorize(message, colors[self.style])} {self.ask}" class ButtonOption(BaseReadonlyOption): """ Triggers actions. Available only in config panels. Renders as a `button` in the web-admin and can be called with `yunohost [app|domain|settings] action run ` in CLI. Every options defined in an action section (a config panel section with at least one `button`) is guaranted to be shown/asked to the user and available in `scripts/config`'s scope. [check examples in advanced use cases](#actions). #### Example ```toml [section.my_option_id] type = "button" ask = "Break the system" style = "danger" icon = "bug" # enabled only if another option's value (a `boolean` for example) is positive enabled = "aknowledged" ``` To be able to trigger an action we have to add a bash function starting with `run__` in your `scripts/config` ```bash run__my_action_id() { ynh_print_info "Running 'my_action_id' action" } ``` #### Properties - [common properties](#common-properties) - `bind`: forced to `"null"` - `style`: any of `"success|info|warning|danger"` (default: `"success"`) - `enabled`: `JSExpression` or `bool` (default: `true`) - when used with `JSExpression` you can enable/disable the button depending on context - `icon` (optional): any icon name from [Fork Awesome](https://forkaweso.me/Fork-Awesome/icons/) - Currently only displayed in the web-admin """ type: Literal[OptionType.button] = OptionType.button bind: Literal["null"] = "null" help: Union[Translation, None] = None style: State = State.success icon: Union[str, None] = None enabled: Union[JSExpression, bool] = 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): """ Rest of the option types available are considered `inputs`. #### Example ```toml [section.my_option_id] type = "string" # …any common props… + optional = false redact = false default = "some default string" help = "You can enter almost anything!" example = "an example string" placeholder = "write something…" ``` #### Properties - [common properties](#common-properties) - `optional`: `bool` (default: `false`, but `true` in config panels) - `redact`: `bool` (default: `false`), to redact the value in the logs when the value contain private information - `default`: depends on `type`, the default value to assign to the option - in case of readonly values, you can use this `default` to assign a value (or return a dynamic `default` from a custom getter) - `help` (optional): `Translation`, to display a short help message in cli and web-admin - `example` (optional): `str`, to display an example value in web-admin only - `placeholder` (optional): `str`, shown in the web-admin fields only """ help: Union[Translation, None] = None example: Union[str, None] = None placeholder: Union[str, None] = None redact: bool = False optional: bool = False # FIXME keep required as default? default: Any = None _annotation = Any _none_as_empty_str: bool = True @validator("default", pre=True) def check_empty_default(value: Any) -> Any: if value == "": return None return value @staticmethod def humanize(value: Any, option={}) -> str: if value is None: return "" return str(value) @staticmethod def normalize(value, option={}): if isinstance(value, str): value = value.strip() return value @property def _dynamic_annotation(self) -> Any: """ Returns the expected type of an Option's value. This may be dynamic based on constraints. """ return self._annotation @property def _validators(self) -> dict[str, Callable]: return { "pre": self._value_pre_validator, "post": self._value_post_validator, } def _get_field_attrs(self) -> dict[str, Any]: """ Returns attributes to build a `pydantic.Field`. This may contains non `Field` attrs that will end up in `Field.extra`. Those extra can be used as constraints in custom validators and ends up in the JSON Schema. """ # TODO # - help # - placeholder attrs: dict[str, Any] = { "redact": self.redact, # extra "none_as_empty_str": self._none_as_empty_str, } if self.readonly: attrs["allow_mutation"] = False if self.example: attrs["examples"] = [self.example] if self.default is not None: attrs["default"] = self.default else: attrs["default"] = ... if not self.optional else None return attrs def _as_dynamic_model_field(self) -> tuple[Any, "FieldInfo"]: """ Return a tuple of a type and a Field instance to be injected in a custom form declaration. """ 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) def _get_prompt_message(self, value: Any) -> str: message = super()._get_prompt_message(value) if self.readonly: message = colorize(message, "purple") return f"{message} {self.humanize(value, self)}" return message @classmethod def _value_pre_validator(cls, value: Any, field: "ModelField") -> Any: if value == "": return None return value @classmethod def _value_post_validator(cls, value: Any, field: "ModelField") -> Any: extras = field.field_info.extra if value is None and extras["none_as_empty_str"]: value = "" if not extras.get("redact"): return value # Tell the operation_logger to redact all password-type / secret args # Also redact the % escaped version of the password that might appear in # the 'args' section of metadata (relevant for password with non-alphanumeric char) data_to_redact = [] if value and isinstance(value, str): data_to_redact.append(value) data_to_redact += [ urllib.parse.quote(data) for data in data_to_redact if urllib.parse.quote(data) != data ] for operation_logger in OperationLogger._instances: operation_logger.data_to_redact.extend(data_to_redact) return value # ─ STRINGS ─────────────────────────────────────────────── class BaseStringOption(BaseInputOption): default: Union[str, None] pattern: Union[Pattern, None] = None _annotation = str @property def _dynamic_annotation(self) -> Type[str]: if self.pattern: return constr(regex=self.pattern.regexp) return self._annotation def _get_field_attrs(self) -> dict[str, Any]: attrs = super()._get_field_attrs() if self.pattern: attrs["regex_error"] = self.pattern.error # extra return attrs class StringOption(BaseStringOption): r""" Ask for a simple string. #### Example ```toml [section.my_option_id] type = "string" default = "E10" pattern.regexp = '^[A-F]\d\d$' pattern.error = "Provide a room like F12 : one uppercase and 2 numbers" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `""` - `pattern` (optional): `Pattern`, a regex to match the value against """ type: Literal[OptionType.string] = OptionType.string class TextOption(BaseStringOption): """ Ask for a multiline string. Renders as a `textarea` in the web-admin and by opening a text editor on the CLI. #### Example ```toml [section.my_option_id] type = "text" default = "multi\\nline\\ncontent" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `""` - `pattern` (optional): `Pattern`, a regex to match the value against """ type: Literal[OptionType.text] = OptionType.text FORBIDDEN_PASSWORD_CHARS = r"{}" class PasswordOption(BaseInputOption): """ Ask for a password. The password is tested as a regular user password (at least 8 chars) #### Example ```toml [section.my_option_id] type = "password" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: forced to `""` - `redact`: forced to `true` - `example`: forbidden """ type: Literal[OptionType.password] = OptionType.password example: Literal[None] = None default: Literal[None] = None redact: Literal[True] = True _annotation = str _forbidden_chars: str = FORBIDDEN_PASSWORD_CHARS def _get_field_attrs(self) -> dict[str, Any]: attrs = super()._get_field_attrs() attrs["forbidden_chars"] = self._forbidden_chars # extra return attrs @classmethod def _value_pre_validator( cls, value: Union[str, None], field: "ModelField" ) -> Union[str, None]: value = super()._value_pre_validator(value, field) if value is not None and value != "": forbidden_chars: str = field.field_info.extra["forbidden_chars"] if any(char in value for char in forbidden_chars): raise YunohostValidationError( "pattern_password_app", forbidden_chars=forbidden_chars ) # If it's an optional argument the value should be empty or strong enough from yunohost.utils.password import assert_password_is_strong_enough assert_password_is_strong_enough("user", value) return value class ColorOption(BaseInputOption): """ Ask for a color represented as a hex value (with possibly an alpha channel). Renders as color picker in the web-admin and as a prompt that accept named color like `yellow` in CLI. #### Example ```toml [section.my_option_id] type = "color" default = "#ff0" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `""` """ type: Literal[OptionType.color] = OptionType.color default: Union[str, None] _annotation = Color @staticmethod def humanize(value: Union[Color, str, None], option={}) -> str: if isinstance(value, Color): value.as_named(fallback=True) return super(ColorOption, ColorOption).humanize(value, option) @staticmethod def normalize(value: Union[Color, str, None], option={}) -> str: if isinstance(value, Color): return value.as_hex() return super(ColorOption, ColorOption).normalize(value, option) @classmethod def _value_post_validator( cls, value: Union[Color, None], field: "ModelField" ) -> Union[str, None]: if isinstance(value, Color): return value.as_hex() return super()._value_post_validator(value, field) # ─ NUMERIC ─────────────────────────────────────────────── class NumberOption(BaseInputOption): """ Ask for a number (an integer). #### Example ```toml [section.my_option_id] type = "number" default = 100 min = 50 max = 200 step = 5 ``` #### Properties - [common inputs properties](#common-inputs-properties) - `type`: `number` or `range` (input or slider in the web-admin) - `min` (optional): minimal int value inclusive - `max` (optional): maximal int value inclusive - `step` (optional): currently only used in the webadmin as the `` step jump """ # `number` and `range` are exactly the same, but `range` does render as a slider in web-admin type: Literal[OptionType.number, OptionType.range] = OptionType.number default: Union[int, None] min: Union[int, None] = None max: Union[int, None] = None step: Union[int, None] = None _annotation = int _none_as_empty_str = False @staticmethod def normalize(value, option={}) -> Union[int, None]: if isinstance(value, int): return value if isinstance(value, str): value = value.strip() if isinstance(value, str) and value.isdigit(): return int(value) if value in [None, ""]: return None option = option.dict() if isinstance(option, BaseOption) else option raise YunohostValidationError( "app_argument_invalid", name=option.get("id"), error=m18n.n("invalid_number"), ) def _get_field_attrs(self) -> dict[str, Any]: attrs = super()._get_field_attrs() attrs["ge"] = self.min attrs["le"] = self.max attrs["step"] = self.step # extra return attrs @classmethod def _value_pre_validator( cls, value: Union[int, None], field: "ModelField" ) -> Union[int, None]: value = super()._value_pre_validator(value, field) if value is None: return None return value # ─ BOOLEAN ─────────────────────────────────────────────── class BooleanOption(BaseInputOption): """ Ask for a boolean. Renders as a switch in the web-admin and a yes/no prompt in CLI. #### Example ```toml [section.my_option_id] type = "boolean" default = 1 yes = "agree" no = "disagree" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `0` - `yes` (optional): (default: `1`) define as what the thruthy value should output - can be `true`, `True`, `"yes"`, etc. - `no` (optional): (default: `0`) define as what the thruthy value should output - can be `0`, `"false"`, `"n"`, etc. """ type: Literal[OptionType.boolean] = OptionType.boolean yes: Any = 1 no: Any = 0 default: Union[bool, int, str, None] = 0 _annotation = Union[bool, int, str] _yes_answers: set[str] = {"1", "yes", "y", "true", "t", "on"} _no_answers: set[str] = {"0", "no", "n", "false", "f", "off"} _none_as_empty_str = False @staticmethod def humanize(value, option={}) -> str: option = option.dict() if isinstance(option, BaseOption) else option yes = option.get("yes", 1) no = option.get("no", 0) value = BooleanOption.normalize(value, option) if value == yes: return "yes" if value == no: return "no" if value is None: return "" raise YunohostValidationError( "app_argument_choice_invalid", name=option.get("id"), value=value, choices="yes/no", ) @staticmethod def normalize(value, option={}) -> Any: option = option.dict() if isinstance(option, BaseOption) else option if isinstance(value, str): value = value.strip() technical_yes = option.get("yes", 1) technical_no = option.get("no", 0) no_answers = BooleanOption._no_answers yes_answers = BooleanOption._yes_answers assert ( str(technical_yes).lower() not in no_answers ), f"'yes' value can't be in {no_answers}" assert ( str(technical_no).lower() not in yes_answers ), f"'no' value can't be in {yes_answers}" no_answers.add(str(technical_no).lower()) yes_answers.add(str(technical_yes).lower()) strvalue = str(value).lower() if strvalue in yes_answers: return technical_yes if strvalue in no_answers: return technical_no if strvalue in ["none", ""]: return None raise YunohostValidationError( "app_argument_choice_invalid", name=option.get("id"), value=strvalue, choices="yes/no", ) def get(self, key, default=None): return getattr(self, key, default) def _get_field_attrs(self) -> dict[str, Any]: attrs = super()._get_field_attrs() attrs["parse"] = { # extra True: self.yes, False: self.no, } return attrs def _get_prompt_message(self, value: Union[bool, None]) -> str: message = super()._get_prompt_message(value) if not self.readonly: message += " [yes | no]" return message @classmethod def _value_post_validator( cls, value: Union[bool, None], field: "ModelField" ) -> Any: if isinstance(value, bool): return field.field_info.extra["parse"][value] return super()._value_post_validator(value, field) # ─ TIME ────────────────────────────────────────────────── class DateOption(BaseInputOption): """ Ask for a date in the form `"2025-06-14"`. Renders as a date-picker in the web-admin and a regular prompt in CLI. Can also take a timestamp as value that will output as an ISO date string. #### Example ```toml [section.my_option_id] type = "date" default = "2070-12-31" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `""` """ type: Literal[OptionType.date] = OptionType.date default: Union[str, None] _annotation = datetime.date @classmethod def _value_post_validator( cls, value: Union[datetime.date, None], field: "ModelField" ) -> Union[str, None]: if isinstance(value, datetime.date): return value.isoformat() return super()._value_post_validator(value, field) class TimeOption(BaseInputOption): """ Ask for an hour in the form `"22:35"`. Renders as a date-picker in the web-admin and a regular prompt in CLI. #### Example ```toml [section.my_option_id] type = "time" default = "12:26" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `""` """ type: Literal[OptionType.time] = OptionType.time default: Union[str, int, None] _annotation = datetime.time @classmethod def _value_post_validator( cls, value: Union[datetime.date, None], field: "ModelField" ) -> Union[str, None]: if isinstance(value, datetime.time): # FIXME could use `value.isoformat()` to get `%H:%M:%S` return value.strftime("%H:%M") return super()._value_post_validator(value, field) # ─ LOCATIONS ───────────────────────────────────────────── class EmailOption(BaseInputOption): """ Ask for an email. Validation made with [python-email-validator](https://github.com/JoshData/python-email-validator) #### Example ```toml [section.my_option_id] type = "email" default = "Abc.123@test-example.com" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `""` """ type: Literal[OptionType.email] = OptionType.email default: Union[EmailStr, None] _annotation = EmailStr class WebPathOption(BaseStringOption): """ Ask for an web path (the part of an url after the domain). Used by default in app install to define from where the app will be accessible. #### Example ```toml [section.my_option_id] type = "path" default = "/" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `""` - `pattern` (optional): `Pattern`, a regex to match the value against """ type: Literal[OptionType.path] = OptionType.path @staticmethod def normalize(value, option={}) -> str: option = option.dict() if isinstance(option, BaseOption) else option if value is None: value = "" if not isinstance(value, str): raise YunohostValidationError( "app_argument_invalid", name=option.get("id"), error="Argument for path should be a string.", ) if not value.strip(): if option.get("optional"): return "" # Hmpf here we could just have a "else" case # but we also want WebPathOption.normalize("") to return "/" # (i.e. if no option is provided, hence .get("optional") is None elif option.get("optional") is False: raise YunohostValidationError( "app_argument_invalid", name=option.get("id"), error="Option is mandatory", ) return "/" + value.strip().strip(" /") class URLOption(BaseStringOption): """ Ask for any url. #### Example ```toml [section.my_option_id] type = "url" default = "https://example.xn--zfr164b/@handle/" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `""` - `pattern` (optional): `Pattern`, a regex to match the value against """ type: Literal[OptionType.url] = OptionType.url _annotation = HttpUrl # ─ FILE ────────────────────────────────────────────────── class FileOption(BaseInputOption): r""" Ask for file. Renders a file prompt in the web-admin and ask for a path in CLI. #### Example ```toml [section.my_option_id] type = "file" accept = ".json" # bind the file to a location to save the file there bind = "/tmp/my_file.json" ``` #### Properties - [common inputs properties](#common-inputs-properties) - `default`: `""` - `accept`: a comma separated list of extension to accept like `".conf, .ini` - /!\ currently only work on the web-admin """ type: Literal[OptionType.file] = OptionType.file # `FilePath` for CLI (path must exists and must be a file) # `bytes` for API (a base64 encoded file actually) accept: Union[list[str], None] = None # currently only used by the web-admin default: Union[str, None] _annotation = str # TODO could be Path at some point _upload_dirs: set[str] = set() @property def _validators(self) -> dict[str, Callable]: return { "pre": self._value_pre_validator, "post": ( self._bash_value_post_validator if self.mode == "bash" else self._python_value_post_validator ), } def _get_field_attrs(self) -> dict[str, Any]: attrs = super()._get_field_attrs() if self.accept: attrs["accept"] = self.accept # extra attrs["bind"] = self.bind return attrs @classmethod def clean_upload_dirs(cls) -> None: # Delete files uploaded from API for upload_dir in cls._upload_dirs: if os.path.exists(upload_dir): shutil.rmtree(upload_dir) @classmethod def _base_value_post_validator( cls, value: Any, field: "ModelField" ) -> tuple[bytes, str | None]: import mimetypes from pathlib import Path from magic import Magic from base64 import b64decode if Moulinette.interface.type != "api": path = Path(value) if not (path.exists() and path.is_absolute() and path.is_file()): raise YunohostValidationError("File doesn't exists", raw_msg=True) content = path.read_bytes() else: content = b64decode(value) accept_list = field.field_info.extra.get("accept") mimetype = Magic(mime=True).from_buffer(content) if accept_list and mimetype not in accept_list: raise YunohostValidationError( f"Unsupported image type : {mimetype}", raw=True ) ext = mimetypes.guess_extension(mimetype) return content, ext @classmethod def _bash_value_post_validator(cls, value: Any, field: "ModelField") -> str: """File handling for "bash" config panels (app)""" if not value: return "" content, _ = cls._base_value_post_validator(value, field) upload_dir = tempfile.mkdtemp(prefix="ynh_filequestion_") _, file_path = tempfile.mkstemp(dir=upload_dir) FileOption._upload_dirs.add(upload_dir) logger.debug(f"Saving file {field.name} for file question into {file_path}") write_to_file(file_path, content, file_mode="wb") return file_path @classmethod def _python_value_post_validator(cls, value: Any, field: "ModelField") -> str: """File handling for "python" config panels""" from pathlib import Path import hashlib if not value: return "" bind = field.field_info.extra["bind"] # to avoid "filename too long" with b64 content if len(value.encode("utf-8")) < 255: # Check if value is an already hashed and saved filepath path = Path(value) if path.exists() and value == bind.format( filename=path.stem, ext=path.suffix ): return value content, ext = cls._base_value_post_validator(value, field) m = hashlib.sha256() m.update(content) sha256sum = m.hexdigest() filename = Path(bind.format(filename=sha256sum, ext=ext)) filename.write_bytes(content) return str(filename) # ─ CHOICES ─────────────────────────────────────────────── class BaseChoicesOption(BaseInputOption): # FIXME probably forbid choices to be None? filter: Union[JSExpression, None] = None # filter before choices # We do not declare `choices` here to be able to declare other fields before `choices` and acces their values in `choices` validators # choices: Union[dict[str, Any], list[Any], None] @validator("choices", pre=True, check_fields=False) def parse_comalist_choices(value: Any) -> Union[dict[str, Any], list[Any], None]: if isinstance(value, str): values = [value.strip() for value in value.split(",")] return [value for value in values if value] return value @property def _dynamic_annotation(self) -> Union[object, Type[str]]: if self.choices is not None: choices = ( self.choices if isinstance(self.choices, list) else self.choices.keys() ) return Literal[tuple(choices)] return self._annotation def _get_prompt_message(self, value: Any) -> str: message = super()._get_prompt_message(value) if self.readonly: if isinstance(self.choices, dict) and value is not None: value = self.choices[value] return f"{colorize(message, 'purple')} {value}" 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 ) splitted_choices = choices[:20] remaining_choices = len(choices[20:]) if remaining_choices > 0: splitted_choices += [ m18n.n("other_available_options", n=remaining_choices) ] choices_to_display = " | ".join(str(choice) for choice in splitted_choices) return f"{message} [{choices_to_display}]" return message class SelectOption(BaseChoicesOption): """ Ask for value from a limited set of values. Renders as a regular `