mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
2189 lines
70 KiB
Python
2189 lines
70 KiB
Python
#
|
|
# 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 <http://www.gnu.org/licenses/>.
|
|
#
|
|
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> <operator> <right>
|
|
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> <ops> <comparators>
|
|
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): # <op> <values>
|
|
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): # <operator> <operand> 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 <action_id>` 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 `<input/>` 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 `<select/>` in the web-admin and as a regular prompt in CLI with autocompletion of `choices`.
|
|
|
|
#### Example
|
|
```toml
|
|
[section.my_option_id]
|
|
type = "select"
|
|
choices = ["one", "two", "three"]
|
|
choices = "one,two,three"
|
|
default = "two"
|
|
```
|
|
#### Properties
|
|
- [common inputs properties](#common-inputs-properties)
|
|
- `default`: `""`, obviously the default has to be empty or an available `choices` item.
|
|
- `choices`: a (coma separated) list of values
|
|
"""
|
|
|
|
type: Literal[OptionType.select] = OptionType.select
|
|
filter: Literal[None] = None
|
|
choices: Union[dict[str, Any], list[Any]]
|
|
default: Union[str, None]
|
|
_annotation = str
|
|
|
|
|
|
class TagsOption(BaseChoicesOption):
|
|
"""
|
|
Ask for series of values. Optionally from a limited set of values.
|
|
Renders as a multi select in the web-admin and as a regular prompt in CLI without autocompletion of `choices`.
|
|
|
|
This output as a coma separated list of strings `"one,two,three"`
|
|
|
|
#### Example
|
|
```toml
|
|
[section.my_option_id]
|
|
type = "tags"
|
|
default = "word,another word"
|
|
|
|
[my_other_option_id]
|
|
type = "tags"
|
|
choices = ["one", "two", "three"]
|
|
# choices = "one,two,three"
|
|
default = "two,three"
|
|
```
|
|
#### Properties
|
|
- [common inputs properties](#common-inputs-properties)
|
|
- `default`: `""`, obviously the default has to be empty or an available `choices` item.
|
|
- `pattern` (optional): `Pattern`, a regex to match all the values against
|
|
- `choices` (optional): a (coma separated) list of values
|
|
- `icon` (optional): any icon name from [Fork Awesome](https://forkaweso.me/Fork-Awesome/icons/)
|
|
- Currently only displayed in the web-admin
|
|
"""
|
|
|
|
type: Literal[OptionType.tags] = OptionType.tags
|
|
filter: Literal[None] = None
|
|
choices: Union[list[str], None] = None
|
|
pattern: Union[Pattern, None] = None
|
|
icon: Union[str, None] = None
|
|
default: Union[str, list[str], None]
|
|
_annotation = str
|
|
|
|
@staticmethod
|
|
def humanize(value, option={}) -> str:
|
|
if isinstance(value, list):
|
|
return ",".join(str(v) for v in value)
|
|
if not value:
|
|
return ""
|
|
return value
|
|
|
|
@staticmethod
|
|
def normalize(value, option={}) -> str:
|
|
if isinstance(value, list):
|
|
return ",".join(str(v) for v in value)
|
|
if isinstance(value, str):
|
|
value = value.strip().strip(",")
|
|
if value is None or value == "":
|
|
return ""
|
|
return value
|
|
|
|
@property
|
|
def _dynamic_annotation(self) -> Type[str]:
|
|
# TODO use Literal when serialization is seperated from validation
|
|
# if self.choices is not None:
|
|
# return Literal[tuple(self.choices)]
|
|
|
|
# Repeat pattern stuff since we can't call the bare class `_dynamic_annotation` prop without instantiating it
|
|
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.choices:
|
|
attrs["choices"] = self.choices # extra
|
|
|
|
return attrs
|
|
|
|
@classmethod
|
|
def _value_pre_validator(
|
|
cls, value: Union[list, str, None], field: "ModelField"
|
|
) -> Union[str, None]:
|
|
if value is None or value == "":
|
|
return None
|
|
|
|
if not isinstance(value, (list, str, type(None))):
|
|
raise YunohostValidationError(
|
|
"app_argument_invalid",
|
|
name=field.name,
|
|
error=f"'{str(value)}' is not a list",
|
|
)
|
|
|
|
if isinstance(value, str):
|
|
value = [v.strip() for v in value.split(",")]
|
|
value = [v for v in value if v]
|
|
|
|
if isinstance(value, list):
|
|
choices = field.field_info.extra.get("choices")
|
|
if choices:
|
|
if not all(v in choices for v in value):
|
|
raise YunohostValidationError(
|
|
"app_argument_choice_invalid",
|
|
name=field.name,
|
|
value=value,
|
|
choices=", ".join(str(choice) for choice in choices),
|
|
)
|
|
|
|
return ",".join(str(v) for v in value)
|
|
return value
|
|
|
|
|
|
# ─ ENTITIES ──────────────────────────────────────────────
|
|
|
|
|
|
class DomainOption(BaseChoicesOption):
|
|
"""
|
|
Ask for a user domain.
|
|
Renders as a select in the web-admin and as a regular prompt in CLI with autocompletion of registered domains.
|
|
|
|
#### Example
|
|
```toml
|
|
[section.my_option_id]
|
|
type = "domain"
|
|
```
|
|
#### Properties
|
|
- [common inputs properties](#common-inputs-properties)
|
|
- `default`: forced to the instance main domain
|
|
"""
|
|
|
|
type: Literal[OptionType.domain] = OptionType.domain
|
|
filter: Literal[None] = None
|
|
choices: Union[dict[str, str], None]
|
|
|
|
@validator("choices", pre=True, always=True)
|
|
def inject_domains_choices(
|
|
cls, value: Union[dict[str, str], None], values: Values
|
|
) -> dict[str, str]:
|
|
# TODO remove calls to resources in validators (pydantic V2 should adress this)
|
|
from yunohost.domain import domain_list
|
|
|
|
data = domain_list()
|
|
return {
|
|
domain: domain + " ★" if domain == data["main"] else domain
|
|
for domain in data["domains"]
|
|
}
|
|
|
|
@validator("default", pre=True, always=True)
|
|
def inject_default(
|
|
cls, value: Union[str, None], values: Values
|
|
) -> Union[str, None]:
|
|
# TODO remove calls to resources in validators (pydantic V2 should adress this)
|
|
from yunohost.domain import _get_maindomain
|
|
|
|
return _get_maindomain()
|
|
|
|
@staticmethod
|
|
def normalize(value, option={}) -> str:
|
|
if value.startswith("https://"):
|
|
value = value[len("https://") :]
|
|
elif value.startswith("http://"):
|
|
value = value[len("http://") :]
|
|
|
|
# Remove trailing slashes
|
|
value = value.rstrip("/").lower()
|
|
|
|
return value
|
|
|
|
|
|
class AppOption(BaseChoicesOption):
|
|
"""
|
|
Ask for a user app.
|
|
Renders as a select in the web-admin and as a regular prompt in CLI with autocompletion of installed apps.
|
|
|
|
#### Example
|
|
```toml
|
|
[section.my_option_id]
|
|
type = "app"
|
|
filter = "is_webapp"
|
|
```
|
|
#### Properties
|
|
- [common inputs properties](#common-inputs-properties)
|
|
- `default`: `""`
|
|
- `filter` (optional): `JSExpression` with what `yunohost app info <app_id> --full` returns as context (only first level keys)
|
|
"""
|
|
|
|
type: Literal[OptionType.app] = OptionType.app
|
|
filter: Union[JSExpression, None] = None
|
|
add_yunohost_portal_to_choices: bool = False
|
|
choices: Union[dict[str, str], None]
|
|
|
|
@validator("choices", pre=True, always=True)
|
|
def inject_apps_choices(
|
|
cls, value: Union[dict[str, str], None], values: Values
|
|
) -> dict[str, str]:
|
|
# TODO remove calls to resources in validators (pydantic V2 should adress this)
|
|
from yunohost.app import app_list
|
|
|
|
apps = app_list(full=True)["apps"]
|
|
|
|
if values.get("filter", None):
|
|
apps = [
|
|
app
|
|
for app in apps
|
|
if evaluate_simple_js_expression(values["filter"], context=app)
|
|
]
|
|
|
|
value = {"_none": "---"}
|
|
|
|
if values.get("add_yunohost_portal_to_choices", False):
|
|
value["_yunohost_portal_with_public_apps"] = "YunoHost's portal with public apps"
|
|
|
|
value.update(
|
|
{
|
|
app["id"]: f"{app['label']} ({app.get('domain_path', app['id'])})"
|
|
for app in apps
|
|
}
|
|
)
|
|
|
|
return value
|
|
|
|
|
|
class UserOption(BaseChoicesOption):
|
|
"""
|
|
Ask for a user.
|
|
Renders as a select in the web-admin and as a regular prompt in CLI with autocompletion of available usernames.
|
|
|
|
#### Example
|
|
```toml
|
|
[section.my_option_id]
|
|
type = "user"
|
|
```
|
|
#### Properties
|
|
- [common inputs properties](#common-inputs-properties)
|
|
- `default`: the first admin user found
|
|
"""
|
|
|
|
type: Literal[OptionType.user] = OptionType.user
|
|
filter: Literal[None] = None
|
|
choices: Union[dict[str, str], None]
|
|
|
|
@root_validator(pre=True)
|
|
def inject_users_choices_and_default(cls, values: Values) -> Values:
|
|
# TODO remove calls to resources in validators (pydantic V2 should adress this)
|
|
from yunohost.user import user_list
|
|
|
|
users = user_list(fields=["username", "fullname", "mail", "groups"])["users"]
|
|
|
|
values["choices"] = {
|
|
username: f"{infos['fullname']} ({infos['mail']})"
|
|
for username, infos in users.items()
|
|
}
|
|
|
|
# FIXME keep this to test if any user, do not raise error if no admin?
|
|
if not values["choices"]:
|
|
raise YunohostValidationError(
|
|
"app_argument_invalid",
|
|
name=values["id"],
|
|
error="You should create a YunoHost user first.",
|
|
)
|
|
|
|
if not values.get("default"):
|
|
values["default"] = next(
|
|
username
|
|
for username, infos in users.items()
|
|
if "admins" in infos["groups"]
|
|
)
|
|
|
|
return values
|
|
|
|
|
|
class GroupOption(BaseChoicesOption):
|
|
"""
|
|
Ask for a group.
|
|
Renders as a select in the web-admin and as a regular prompt in CLI with autocompletion of available groups.
|
|
|
|
#### Example
|
|
```toml
|
|
[section.my_option_id]
|
|
type = "group"
|
|
default = "visitors"
|
|
```
|
|
#### Properties
|
|
- [common inputs properties](#common-inputs-properties)
|
|
- `default`: `"all_users"`, `"visitors"` or `"admins"` (default: `"all_users"`)
|
|
"""
|
|
|
|
type: Literal[OptionType.group] = OptionType.group
|
|
filter: Literal[None] = None
|
|
choices: Union[dict[str, str], None]
|
|
default: Union[Literal["visitors", "all_users", "admins"], None] = "all_users"
|
|
|
|
@validator("choices", pre=True, always=True)
|
|
def inject_groups_choices(
|
|
cls, value: Union[dict[str, str], None], values: Values
|
|
) -> dict[str, str]:
|
|
# TODO remove calls to resources in validators (pydantic V2 should adress this)
|
|
from yunohost.user import user_group_list
|
|
|
|
groups = user_group_list(short=True, include_primary_groups=False)["groups"]
|
|
|
|
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
|
|
)
|
|
|
|
return {groupname: _human_readable_group(groupname) for groupname in groups}
|
|
|
|
@validator("default", pre=True, always=True)
|
|
def inject_default(cls, value: Union[str, None], values: Values) -> str:
|
|
# FIXME do we really want to default to something all the time?
|
|
if value is None:
|
|
return "all_users"
|
|
return value
|
|
|
|
|
|
OPTIONS = {
|
|
OptionType.display_text: DisplayTextOption,
|
|
OptionType.markdown: MarkdownOption,
|
|
OptionType.alert: AlertOption,
|
|
OptionType.button: ButtonOption,
|
|
OptionType.string: StringOption,
|
|
OptionType.text: TextOption,
|
|
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,
|
|
}
|
|
|
|
AnyOption = Union[
|
|
DisplayTextOption,
|
|
MarkdownOption,
|
|
AlertOption,
|
|
ButtonOption,
|
|
StringOption,
|
|
TextOption,
|
|
PasswordOption,
|
|
ColorOption,
|
|
NumberOption,
|
|
BooleanOption,
|
|
DateOption,
|
|
TimeOption,
|
|
EmailOption,
|
|
WebPathOption,
|
|
URLOption,
|
|
FileOption,
|
|
SelectOption,
|
|
TagsOption,
|
|
DomainOption,
|
|
AppOption,
|
|
UserOption,
|
|
GroupOption,
|
|
]
|
|
|
|
|
|
# ╭───────────────────────────────────────────────────────╮
|
|
# │ ┌─╴╭─╮┌─╮╭╮╮ │
|
|
# │ ├─╴│ │├┬╯│││ │
|
|
# │ ╵ ╰─╯╵ ╰╵╵╵ │
|
|
# ╰───────────────────────────────────────────────────────╯
|
|
|
|
|
|
class OptionsModel(BaseModel):
|
|
# Pydantic will match option types to their models class based on the "type" attribute
|
|
options: list[Annotated[AnyOption, Field(discriminator="type")]]
|
|
|
|
@staticmethod
|
|
def options_dict_to_list(
|
|
options: dict[str, Any], optional: bool = False
|
|
) -> list[dict[str, Any]]:
|
|
options_list = []
|
|
|
|
for id_, data in options.items():
|
|
option = data | {
|
|
"id": data.get("id", id_),
|
|
"type": data.get(
|
|
"type",
|
|
OptionType.select if "choices" in data else OptionType.string,
|
|
),
|
|
}
|
|
|
|
if option["type"] not in READONLY_TYPES:
|
|
option["optional"] = option.get("optional", optional)
|
|
|
|
# LEGACY (`choices` in option `string` used to be valid)
|
|
if "choices" in option and option["type"] == OptionType.string:
|
|
logger.warning(
|
|
f"Packagers: option {id_} has 'choices' but has type 'string', use 'select' instead to remove this warning."
|
|
)
|
|
option["type"] = OptionType.select
|
|
|
|
options_list.append(option)
|
|
|
|
return options_list
|
|
|
|
def __init__(self, **kwargs) -> None:
|
|
super().__init__(options=self.options_dict_to_list(kwargs))
|
|
|
|
def translate_options(self, i18n_key: Union[str, None] = 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 warn?
|
|
option.ask = option.id
|
|
|
|
|
|
class FormModel(BaseModel):
|
|
"""
|
|
Base form on which dynamic forms are built upon Options.
|
|
"""
|
|
|
|
class Config:
|
|
validate_assignment = True
|
|
extra = Extra.ignore
|
|
|
|
def __getitem__(self, name: str) -> Any:
|
|
# FIXME
|
|
# if a FormModel's required field is not instancied with a value, it is
|
|
# not available as an attr and therefor triggers an `AttributeError`
|
|
# Also since `BaseReadonlyOption`s do not end up in form,
|
|
# `form[AlertOption.id]` would also triggers an error
|
|
# For convinience in those 2 cases, we return `None`
|
|
if not hasattr(self, name):
|
|
# Return None to trigger a validation error instead for required fields
|
|
return None
|
|
|
|
return getattr(self, name)
|
|
|
|
def __setitem__(self, name: str, value: Any) -> None:
|
|
setattr(self, name, value)
|
|
|
|
def get(self, attr: str, default: Any = None) -> Any:
|
|
try:
|
|
return getattr(self, attr)
|
|
except AttributeError:
|
|
return default
|
|
|
|
|
|
def build_form(
|
|
options: Iterable[AnyOption], name: str = "DynamicForm"
|
|
) -> Type[FormModel]:
|
|
"""
|
|
Returns a dynamic pydantic model class that can be used as a form.
|
|
Parsing/validation occurs at instanciation and assignements.
|
|
To avoid validation at instanciation, use `my_form.construct(**values)`
|
|
"""
|
|
options_as_fields: Any = {}
|
|
validators: dict[str, Any] = {}
|
|
|
|
for option in options:
|
|
if not isinstance(option, BaseInputOption):
|
|
continue # filter out non input options
|
|
|
|
options_as_fields[option.id] = option._as_dynamic_model_field()
|
|
option_validators = option._validators
|
|
|
|
for step in ("pre", "post"):
|
|
validators[f"{option.id}_{step}_validator"] = validator(
|
|
option.id, allow_reuse=True, pre=step == "pre"
|
|
)(option_validators[step])
|
|
|
|
return cast(
|
|
Type[FormModel],
|
|
create_model(
|
|
name,
|
|
__base__=FormModel,
|
|
__validators__=validators,
|
|
**options_as_fields,
|
|
),
|
|
)
|
|
|
|
|
|
# ╭───────────────────────────────────────────────────────╮
|
|
# │ ╷ ╷╶┬╴╶┬╴╷ ╭─╴ │
|
|
# │ │ │ │ │ │ ╰─╮ │
|
|
# │ ╰─╯ ╵ ╶┴╴╰─╴╶─╯ │
|
|
# ╰───────────────────────────────────────────────────────╯
|
|
|
|
|
|
Hooks = dict[str, Callable[[BaseInputOption], Any]]
|
|
|
|
|
|
def parse_prefilled_values(
|
|
args: Union[str, None] = None,
|
|
args_file: Union[str, None] = None,
|
|
method: Literal["parse_qs", "parse_qsl"] = "parse_qs",
|
|
) -> dict[str, Any]:
|
|
"""
|
|
Retrieve form values from yaml file or query string.
|
|
"""
|
|
values: Values = {}
|
|
if args_file:
|
|
# Import YAML / JSON file
|
|
values |= read_yaml(args_file)
|
|
if args:
|
|
# FIXME See `ask_questions_and_parse_answers`
|
|
parsed = getattr(urllib.parse, method)(args, keep_blank_values=True)
|
|
if isinstance(parsed, dict): # parse_qs
|
|
# FIXME could do the following to get a list directly?
|
|
# k: None if not len(v) else (v if len(v) > 1 else v[0])
|
|
values |= {k: ",".join(v) for k, v in parsed.items()}
|
|
else:
|
|
values |= dict(parsed)
|
|
|
|
return values
|
|
|
|
|
|
MAX_RETRIES = 4
|
|
|
|
|
|
def prompt_or_validate_form(
|
|
options: Iterable[AnyOption],
|
|
form: FormModel,
|
|
prefilled_answers: dict[str, Any] = {},
|
|
context: Context = {},
|
|
hooks: Hooks = {},
|
|
) -> FormModel:
|
|
for option in options:
|
|
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=option.help,
|
|
)
|
|
|
|
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
|
|
context[option.id] = None
|
|
|
|
continue
|
|
|
|
# if we try to get a `BaseReadonlyOption` value, which doesn't exists in the form,
|
|
# we get `None`
|
|
value = form[option.id]
|
|
|
|
if isinstance(option, BaseReadonlyOption) or option.readonly:
|
|
if isinstance(option, BaseInputOption):
|
|
# only update the context with the value
|
|
context[option.id] = option.normalize(form[option.id])
|
|
|
|
# FIXME here we could error out
|
|
if option.id in prefilled_answers:
|
|
logger.warning(
|
|
f"'{option.id}' is readonly, value '{prefilled_answers[option.id]}' is then ignored."
|
|
)
|
|
|
|
if interactive:
|
|
Moulinette.display(option._get_prompt_message(value))
|
|
|
|
continue
|
|
|
|
for i in range(5):
|
|
if option.id in prefilled_answers:
|
|
value = prefilled_answers[option.id]
|
|
elif interactive:
|
|
value = option.humanize(value, option)
|
|
choices = (
|
|
option.choices if isinstance(option, BaseChoicesOption) else []
|
|
)
|
|
value = Moulinette.prompt(
|
|
message=option._get_prompt_message(value),
|
|
is_password=isinstance(option, PasswordOption),
|
|
confirm=False,
|
|
prefill=value,
|
|
is_multiline=isinstance(option, TextOption),
|
|
autocomplete=choices,
|
|
help=option.help,
|
|
)
|
|
|
|
# Apply default value if none
|
|
if value is None or value == "" and option.default is not None:
|
|
value = option.default
|
|
|
|
try:
|
|
# Normalize and validate
|
|
form[option.id] = option.normalize(value, option)
|
|
context[option.id] = form[option.id]
|
|
except (ValidationError, YunohostValidationError) as e:
|
|
# If in interactive cli, re-ask the current question
|
|
if i < MAX_RETRIES and interactive:
|
|
logger.error(
|
|
"\n".join([err["msg"] for err in e.errors()])
|
|
if isinstance(e, ValidationError)
|
|
else str(e)
|
|
)
|
|
value = None
|
|
continue
|
|
|
|
if isinstance(e, ValidationError):
|
|
error = "\n".join([err["msg"] for err in e.errors()])
|
|
raise YunohostValidationError(error, raw_msg=True)
|
|
|
|
# Otherwise raise the ValidationError
|
|
raise e
|
|
|
|
break
|
|
|
|
# Search for post actions in hooks
|
|
post_hook = f"post_ask__{option.id}"
|
|
if post_hook in hooks:
|
|
# Hooks looks like they can return multiple values, validate those
|
|
values = hooks[post_hook](option)
|
|
for option_id, value in values.items():
|
|
option = next(opt for opt in options if option.id == option_id)
|
|
if option and isinstance(option, BaseInputOption):
|
|
form[option.id] = option.normalize(value, option)
|
|
context[option.id] = form[option.id]
|
|
|
|
return form
|
|
|
|
|
|
def ask_questions_and_parse_answers(
|
|
raw_options: dict[str, Any],
|
|
prefilled_answers: Union[str, Mapping[str, Any]] = {},
|
|
current_values: Mapping[str, Any] = {},
|
|
hooks: Hooks = {},
|
|
) -> tuple[list[AnyOption], FormModel]:
|
|
"""Parse arguments store in either manifest.json or actions.json or from a
|
|
config panel against the user answers when they are present.
|
|
|
|
Keyword arguments:
|
|
raw_questions -- the arguments description store in yunohost
|
|
format from actions.json/toml, manifest.json/toml
|
|
or config_panel.json/toml
|
|
prefilled_answers -- a url "query-string" such as "domain=yolo.test&path=/foobar&admin=sam"
|
|
or a dict such as {"domain": "yolo.test", "path": "/foobar", "admin": "sam"}
|
|
"""
|
|
|
|
if isinstance(prefilled_answers, str):
|
|
# FIXME FIXME : this is not uniform with config_set() which uses parse.qs (no l)
|
|
# parse_qsl parse single values
|
|
# whereas parse.qs return list of values (which is useful for tags, etc)
|
|
# For now, let's not migrate this piece of code to parse_qs
|
|
# Because Aleks believes some bits of the app CI rely on overriding values (e.g. foo=foo&...&foo=bar)
|
|
answers = parse_prefilled_values(prefilled_answers, method="parse_qsl")
|
|
elif isinstance(prefilled_answers, Mapping):
|
|
answers = {**prefilled_answers}
|
|
else:
|
|
answers = {}
|
|
|
|
context = {**current_values, **answers}
|
|
|
|
model_options = parse_raw_options(raw_options, serialize=False)
|
|
# Build the form from those questions and instantiate it without
|
|
# parsing/validation (construct) since it may contains required questions.
|
|
form = build_form(model_options).construct()
|
|
form = prompt_or_validate_form(
|
|
model_options, form, prefilled_answers=answers, context=context, hooks=hooks
|
|
)
|
|
return (model_options, form)
|
|
|
|
|
|
@overload
|
|
def parse_raw_options(
|
|
raw_options: dict[str, Any], serialize: Literal[True]
|
|
) -> list[dict[str, Any]]:
|
|
...
|
|
|
|
|
|
@overload
|
|
def parse_raw_options(
|
|
raw_options: dict[str, Any], serialize: Literal[False] = False
|
|
) -> list[AnyOption]:
|
|
...
|
|
|
|
|
|
def parse_raw_options(
|
|
raw_options: dict[str, Any], serialize: bool = False
|
|
) -> Union[list[dict[str, Any]], list[AnyOption]]:
|
|
# Validate/parse the options attributes
|
|
try:
|
|
model = OptionsModel(**raw_options)
|
|
except ValidationError as e:
|
|
error = "\n".join([err["msg"] for err in e.errors()])
|
|
raise YunohostError(error, raw_msg=True)
|
|
|
|
model.translate_options()
|
|
|
|
if serialize:
|
|
return model.dict()["options"]
|
|
|
|
return model.options
|