mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
[enh] Add visible attribute support in cli
This commit is contained in:
parent
bbba01a72c
commit
7425684552
2 changed files with 259 additions and 19 deletions
|
@ -15,6 +15,7 @@ from yunohost.utils.config import (
|
|||
PathQuestion,
|
||||
BooleanQuestion,
|
||||
FileQuestion,
|
||||
evaluate_simple_js_expression
|
||||
)
|
||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||
|
||||
|
@ -2093,3 +2094,98 @@ def test_normalize_path():
|
|||
assert PathQuestion.normalize("/macnuggets/") == "/macnuggets"
|
||||
assert PathQuestion.normalize("macnuggets/") == "/macnuggets"
|
||||
assert PathQuestion.normalize("////macnuggets///") == "/macnuggets"
|
||||
|
||||
def test_simple_evaluate():
|
||||
context = {
|
||||
'a1': 1,
|
||||
'b2': 2,
|
||||
'c10': 10,
|
||||
'foo': 'bar',
|
||||
'comp': '1>2',
|
||||
'empty': '',
|
||||
'lorem': 'Lorem ipsum dolor et si qua met!',
|
||||
'warning': 'Warning! This sentence will fail!',
|
||||
'quote': "Je s'apelle Groot",
|
||||
'and_': '&&',
|
||||
'object': { 'a': 'Security risk' }
|
||||
}
|
||||
supported = {
|
||||
'42': 42,
|
||||
'9.5': 9.5,
|
||||
"'bopbidibopbopbop'": 'bopbidibopbopbop',
|
||||
'true': True,
|
||||
'false': False,
|
||||
'null': None,
|
||||
|
||||
# Math
|
||||
'1 * (2 + 3 * (4 - 3))': 5,
|
||||
'1 * (2 + 3 * (4 - 3)) > 10 - 2 || 3 * 2 > 9 - 2 * 3': True,
|
||||
'(9 - 2) * 3 - 10': 11,
|
||||
'12 - 2 * -2 + (3 - 4) * 3.1': 12.9,
|
||||
'9 / 12 + 12 * 3 - 5': 31.75,
|
||||
'9 / 12 + 12 * (3 - 5)': -23.25,
|
||||
'12 > 13.1': False,
|
||||
'12 < 14': True,
|
||||
'12 <= 14': True,
|
||||
'12 >= 14': False,
|
||||
'12 == 14': False,
|
||||
'12 % 5 > 3': False,
|
||||
'12 != 14': True,
|
||||
'9 - 1 > 10 && 3 * 5 > 10': False,
|
||||
'9 - 1 > 10 || 3 * 5 > 10': True,
|
||||
'a1 > 0 || a1 < -12': True,
|
||||
'a1 > 0 && a1 < -12': False,
|
||||
'a1 + 1 > 0 && -a1 > -12': True,
|
||||
'-(a1 + 1) < 0 || -(a1 + 2) > -12': True,
|
||||
'-a1 * 2': -2,
|
||||
'(9 - 2) * 3 - c10': 11,
|
||||
'(9 - b2) * 3 - c10': 11,
|
||||
'c10 > b2': True,
|
||||
|
||||
# String
|
||||
"foo == 'bar'":True,
|
||||
"foo != 'bar'":False,
|
||||
'foo == "bar" && 1 > 0':True,
|
||||
'!!foo': True,
|
||||
'!foo': False,
|
||||
'foo': 'bar',
|
||||
'!(foo > "baa") || 1 > 2': False,
|
||||
'!(foo > "baa") || 1 < 2': True,
|
||||
'empty == ""': True,
|
||||
'1 == "1"': True,
|
||||
'1.0 == "1"': True,
|
||||
'1 == "aaa"': False,
|
||||
"'I am ' + b2 + ' years'": 'I am 2 years',
|
||||
"quote == 'Je s\\'apelle Groot'": True,
|
||||
"lorem == 'Lorem ipsum dolor et si qua met!'": True,
|
||||
"and_ == '&&'": True,
|
||||
"warning == 'Warning! This sentence will fail!'": True,
|
||||
|
||||
# Match
|
||||
"match(lorem, '^Lorem [ia]psumE?')": bool,
|
||||
"match(foo, '^Lorem [ia]psumE?')": None,
|
||||
"match(lorem, '^Lorem [ia]psumE?') && 1 == 1": bool,
|
||||
|
||||
# No code
|
||||
"": False,
|
||||
" ": False,
|
||||
}
|
||||
trigger_errors = {
|
||||
"object.a": YunohostError, # Keep unsupported, for security reasons
|
||||
'a1 ** b2': YunohostError, # Keep unsupported, for security reasons
|
||||
'().__class__.__bases__[0].__subclasses__()': YunohostError, # Very dangerous code
|
||||
'a1 > 11 ? 1 : 0': SyntaxError,
|
||||
'c10 > b2 == false': YunohostError, # JS and Python doesn't do the same thing for this situation
|
||||
'c10 > b2 == true': YunohostError,
|
||||
}
|
||||
|
||||
for expression, result in supported.items():
|
||||
if result == bool:
|
||||
assert bool(evaluate_simple_js_expression(expression, context)), expression
|
||||
else:
|
||||
assert evaluate_simple_js_expression(expression, context) == result, expression
|
||||
|
||||
for expression, error in trigger_errors.items():
|
||||
with pytest.raises(error):
|
||||
evaluate_simple_js_expression(expression, context)
|
||||
|
||||
|
|
|
@ -24,6 +24,8 @@ import re
|
|||
import urllib.parse
|
||||
import tempfile
|
||||
import shutil
|
||||
import ast
|
||||
import operator as op
|
||||
from collections import OrderedDict
|
||||
from typing import Optional, Dict, List, Union, Any, Mapping
|
||||
|
||||
|
@ -46,6 +48,138 @@ from yunohost.log import OperationLogger
|
|||
logger = getActionLogger("yunohost.config")
|
||||
CONFIG_PANEL_VERSION_SUPPORTED = 1.0
|
||||
|
||||
# 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={}):
|
||||
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) == ast.Add:
|
||||
if isinstance(left, str) or isinstance(right, str): # support 'I am ' + 42
|
||||
left = str(left)
|
||||
right = str(right)
|
||||
elif type(left) != type(right): # support "111" - "1" -> 110
|
||||
left = float(left)
|
||||
right = float(right)
|
||||
|
||||
return operators[type(node.op)](left, right)
|
||||
|
||||
# Comparison
|
||||
# JS and Python don't give the same result for multi operators
|
||||
# like True == 10 > 2.
|
||||
elif isinstance(node, ast.Compare) and len(node.comparators) == 1: # <left> <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) == 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>
|
||||
values = node.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, context={}):
|
||||
if not expr.strip():
|
||||
return False
|
||||
node = ast.parse(js_to_python(expr), mode="eval").body
|
||||
return evaluate_simple_ast(node, context)
|
||||
|
||||
class ConfigPanel:
|
||||
def __init__(self, config_path, save_path=None):
|
||||
|
@ -466,11 +600,13 @@ class Question(object):
|
|||
hide_user_input_in_prompt = False
|
||||
pattern: Optional[Dict] = None
|
||||
|
||||
def __init__(self, question: Dict[str, Any]):
|
||||
def __init__(self, question: Dict[str, Any], context: Dict[str, Any] = {}):
|
||||
self.name = question["name"]
|
||||
self.type = question.get("type", "string")
|
||||
self.default = question.get("default", None)
|
||||
self.optional = question.get("optional", False)
|
||||
self.visible = question.get("visible", None)
|
||||
self.context = context
|
||||
self.choices = question.get("choices", [])
|
||||
self.pattern = question.get("pattern", self.pattern)
|
||||
self.ask = question.get("ask", {"en": self.name})
|
||||
|
@ -512,6 +648,15 @@ class Question(object):
|
|||
)
|
||||
|
||||
def ask_if_needed(self):
|
||||
|
||||
if self.visible and not evaluate_simple_js_expression(self.visible, context=self.context):
|
||||
# FIXME There could be several use case if the question is not displayed:
|
||||
# - we doesn't want to give a specific value
|
||||
# - we want to keep the previous value
|
||||
# - we want the default value
|
||||
self.value = None
|
||||
return self.value
|
||||
|
||||
for i in range(5):
|
||||
# Display question if no value filled or if it's a readonly message
|
||||
if Moulinette.interface.type == "cli" and os.isatty(1):
|
||||
|
@ -577,7 +722,7 @@ class Question(object):
|
|||
# Prevent displaying a shitload of choices
|
||||
# (e.g. 100+ available users when choosing an app admin...)
|
||||
choices = (
|
||||
list(self.choices.values())
|
||||
list(self.choices.keys())
|
||||
if isinstance(self.choices, dict)
|
||||
else self.choices
|
||||
)
|
||||
|
@ -710,8 +855,8 @@ class PasswordQuestion(Question):
|
|||
default_value = ""
|
||||
forbidden_chars = "{}"
|
||||
|
||||
def __init__(self, question):
|
||||
super().__init__(question)
|
||||
def __init__(self, question, context: Dict[str, Any] = {}):
|
||||
super().__init__(question, context)
|
||||
self.redact = True
|
||||
if self.default is not None:
|
||||
raise YunohostValidationError(
|
||||
|
@ -829,8 +974,8 @@ class BooleanQuestion(Question):
|
|||
choices="yes/no",
|
||||
)
|
||||
|
||||
def __init__(self, question):
|
||||
super().__init__(question)
|
||||
def __init__(self, question, context: Dict[str, Any] = {}):
|
||||
super().__init__(question, context)
|
||||
self.yes = question.get("yes", 1)
|
||||
self.no = question.get("no", 0)
|
||||
if self.default is None:
|
||||
|
@ -850,10 +995,10 @@ class BooleanQuestion(Question):
|
|||
class DomainQuestion(Question):
|
||||
argument_type = "domain"
|
||||
|
||||
def __init__(self, question):
|
||||
def __init__(self, question, context: Dict[str, Any] = {}):
|
||||
from yunohost.domain import domain_list, _get_maindomain
|
||||
|
||||
super().__init__(question)
|
||||
super().__init__(question, context)
|
||||
|
||||
if self.default is None:
|
||||
self.default = _get_maindomain()
|
||||
|
@ -876,11 +1021,11 @@ class DomainQuestion(Question):
|
|||
class UserQuestion(Question):
|
||||
argument_type = "user"
|
||||
|
||||
def __init__(self, question):
|
||||
def __init__(self, question, context: Dict[str, Any] = {}):
|
||||
from yunohost.user import user_list, user_info
|
||||
from yunohost.domain import _get_maindomain
|
||||
|
||||
super().__init__(question)
|
||||
super().__init__(question, context)
|
||||
self.choices = list(user_list()["users"].keys())
|
||||
|
||||
if not self.choices:
|
||||
|
@ -902,8 +1047,8 @@ class NumberQuestion(Question):
|
|||
argument_type = "number"
|
||||
default_value = None
|
||||
|
||||
def __init__(self, question):
|
||||
super().__init__(question)
|
||||
def __init__(self, question, context: Dict[str, Any] = {}):
|
||||
super().__init__(question, context)
|
||||
self.min = question.get("min", None)
|
||||
self.max = question.get("max", None)
|
||||
self.step = question.get("step", None)
|
||||
|
@ -954,8 +1099,8 @@ class DisplayTextQuestion(Question):
|
|||
argument_type = "display_text"
|
||||
readonly = True
|
||||
|
||||
def __init__(self, question):
|
||||
super().__init__(question)
|
||||
def __init__(self, question, context: Dict[str, Any] = {}):
|
||||
super().__init__(question, context)
|
||||
|
||||
self.optional = True
|
||||
self.style = question.get(
|
||||
|
@ -989,8 +1134,8 @@ class FileQuestion(Question):
|
|||
if os.path.exists(upload_dir):
|
||||
shutil.rmtree(upload_dir)
|
||||
|
||||
def __init__(self, question):
|
||||
super().__init__(question)
|
||||
def __init__(self, question, context: Dict[str, Any] = {}):
|
||||
super().__init__(question, context)
|
||||
self.accept = question.get("accept", "")
|
||||
|
||||
def _prevalidate(self):
|
||||
|
@ -1089,9 +1234,8 @@ def ask_questions_and_parse_answers(
|
|||
for question in questions:
|
||||
question_class = ARGUMENTS_TYPE_PARSERS[question.get("type", "string")]
|
||||
question["value"] = prefilled_answers.get(question["name"])
|
||||
question = question_class(question)
|
||||
|
||||
question.ask_if_needed()
|
||||
question = question_class(question, prefilled_answers)
|
||||
prefilled_answers[question.name] = question.ask_if_needed()
|
||||
out.append(question)
|
||||
|
||||
return out
|
||||
|
|
Loading…
Add table
Reference in a new issue