[enh] config panel python method hook

This commit is contained in:
ljf 2021-10-13 19:28:15 +02:00
parent 8dbc93c674
commit 9f7fb61b50
3 changed files with 143 additions and 60 deletions

View file

@ -1608,14 +1608,9 @@ def app_config_set(
class AppConfigPanel(ConfigPanel):
def __init__(self, app):
# Check app is installed
_assert_is_installed(app)
self.app = app
config_path = os.path.join(APPS_SETTING_PATH, app, "config_panel.toml")
super().__init__(config_path=config_path)
entity_type = "app"
save_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/settings.yml")
config_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/config_panel.yml")
def _load_current_values(self):
self.values = self._call_config_script("show")
@ -1639,7 +1634,7 @@ class AppConfigPanel(ConfigPanel):
from yunohost.hook import hook_exec
# Add default config script if needed
config_script = os.path.join(APPS_SETTING_PATH, self.app, "scripts", "config")
config_script = os.path.join(APPS_SETTING_PATH, self.entity, "scripts", "config")
if not os.path.exists(config_script):
logger.debug("Adding a default config script")
default_script = """#!/bin/bash
@ -1651,15 +1646,15 @@ ynh_app_config_run $1
# Call config script to extract current values
logger.debug(f"Calling '{action}' action from config script")
app_id, app_instance_nb = _parse_app_instance_name(self.app)
app_id, app_instance_nb = _parse_app_instance_name(self.entity)
settings = _get_app_settings(app_id)
env.update(
{
"app_id": app_id,
"app": self.app,
"app": self.entity,
"app_instance_nb": str(app_instance_nb),
"final_path": settings.get("final_path", ""),
"YNH_APP_BASEDIR": os.path.join(APPS_SETTING_PATH, self.app),
"YNH_APP_BASEDIR": os.path.join(APPS_SETTING_PATH, self.entity),
}
)

View file

@ -452,14 +452,9 @@ def domain_config_set(
class DomainConfigPanel(ConfigPanel):
def __init__(self, domain):
_assert_domain_exists(domain)
self.domain = domain
self.save_mode = "diff"
super().__init__(
config_path=DOMAIN_CONFIG_PATH,
save_path=f"{DOMAIN_SETTINGS_DIR}/{domain}.yml",
)
entity_type = "domain"
save_path_tpl = f"{DOMAIN_SETTINGS_PATH}/{entity}.yml")
save_mode = "diff"
def _get_toml(self):
from yunohost.dns import _get_registrar_config_section

View file

@ -19,6 +19,7 @@
"""
import glob
import os
import re
import urllib.parse
@ -27,7 +28,7 @@ import shutil
import ast
import operator as op
from collections import OrderedDict
from typing import Optional, Dict, List, Union, Any, Mapping
from typing import Optional, Dict, List, Union, Any, Mapping, Callable
from moulinette.interfaces.cli import colorize
from moulinette import Moulinette, m18n
@ -189,13 +190,45 @@ def evaluate_simple_js_expression(expr, context={}):
class ConfigPanel:
def __init__(self, config_path, save_path=None):
entity_type = "config"
save_path_tpl = None
config_path_tpl = "/usr/share/yunohost/other/config_{entity}.toml"
@classmethod
def list(cls):
"""
List available config panel
"""
try:
entities = [re.match("^" + cls.save_path_tpl.format(entity="(?p<entity>)") + "$", f).group('entity')
for f in glob.glob(cls.save_path_tpl.format(entity="*"))
if os.path.isfile(f)]
except FileNotFoundError:
entities = []
return entities
def __init__(self, entity, config_path=None, save_path=None, creation=False):
self.entity = entity
self.config_path = config_path
if not config_path:
self.config_path = self.config_path_tpl.format(entity=entity)
self.save_path = save_path
if not save_path and self.save_path_tpl:
self.save_path = self.save_path_tpl.format(entity=entity)
self.config = {}
self.values = {}
self.new_values = {}
if self.save_path and not creation and not os.path.exists(self.save_path):
raise YunohostError(f"{self.entity_type}_doesnt_exists", name=entity)
if self.save_path and creation and os.path.exists(self.save_path):
raise YunohostError(f"{self.entity_type}_already_exists", name=entity)
# Search for hooks in the config panel
self.hooks = {func: getattr(self, func)
for func in dir(self)
if callable(getattr(self, func)) and re.match("^(validate|post_ask)__", func)}
def get(self, key="", mode="classic"):
self.filter_key = key or ""
@ -274,19 +307,12 @@ class ConfigPanel:
# Import and parse pre-answered options
logger.debug("Import and parse pre-answered options")
args = urllib.parse.parse_qs(args or "", keep_blank_values=True)
self.args = {key: ",".join(value_) for key, value_ in args.items()}
if args_file:
# Import YAML / JSON file but keep --args values
self.args = {**read_yaml(args_file), **self.args}
if value is not None:
self.args = {self.filter_key.split(".")[-1]: value}
self._parse_pre_answered(args, value, args_file)
# Read or get values and hydrate the config
self._load_current_values()
self._hydrate()
Question.operation_logger = operation_logger
self._ask()
if operation_logger:
@ -525,7 +551,12 @@ class ConfigPanel:
display_header(f"\n# {name}")
# Check and ask unanswered questions
questions = ask_questions_and_parse_answers(section["options"], self.args)
questions = ask_questions_and_parse_answers(
section["options"],
prefilled_answers=self.args,
current_values=self.values,
hooks=self.hooks
)
self.new_values.update(
{
question.name: question.value
@ -543,20 +574,42 @@ class ConfigPanel:
if "default" in option
}
@property
def future_values(self): # TODO put this in ConfigPanel ?
return {**self.values, **self.new_values}
def __getattr__(self, name):
if "new_values" in self.__dict__ and name in self.new_values:
return self.new_values[name]
if "values" in self.__dict__ and name in self.values:
return self.values[name]
return self.__dict__[name]
def _load_current_values(self):
"""
Retrieve entries in YAML file
And set default values if needed
"""
# Retrieve entries in the YAML
on_disk_settings = {}
if os.path.exists(self.save_path) and os.path.isfile(self.save_path):
on_disk_settings = read_yaml(self.save_path) or {}
# Inject defaults if needed (using the magic .update() ;))
self.values = self._get_default_values()
self.values.update(on_disk_settings)
# Retrieve entries in the YAML
if os.path.exists(self.save_path) and os.path.isfile(self.save_path):
self.values.update(read_yaml(self.save_path) or {})
def _parse_pre_answered(self, args, value, args_file):
args = urllib.parse.parse_qs(args or "", keep_blank_values=True)
self.args = {key: ",".join(value_) for key, value_ in args.items()}
if args_file:
# Import YAML / JSON file but keep --args values
self.args = {**read_yaml(args_file), **self.args}
if value is not None:
self.args = {self.filter_key.split(".")[-1]: value}
def _apply(self):
logger.info("Saving the new configuration...")
@ -564,7 +617,7 @@ class ConfigPanel:
if not os.path.exists(dir_path):
mkdir(dir_path, mode=0o700)
values_to_save = {**self.values, **self.new_values}
values_to_save = self.future_values
if self.save_mode == "diff":
defaults = self._get_default_values()
values_to_save = {
@ -587,8 +640,8 @@ class ConfigPanel:
if services_to_reload:
logger.info("Reloading services...")
for service in services_to_reload:
if hasattr(self, "app"):
service = service.replace("__APP__", self.app)
if hasattr(self, "entity"):
service = service.replace("__APP__", self.entity)
service_reload_or_restart(service)
def _iterate(self, trigger=["option"]):
@ -607,13 +660,16 @@ class Question(object):
hide_user_input_in_prompt = False
pattern: Optional[Dict] = None
def __init__(self, question: Dict[str, Any], context: Mapping[str, Any] = {}):
def __init__(self, question: Dict[str, Any],
context: Mapping[str, Any] = {},
hooks: Dict[str, Callable] = {}):
self.name = question["name"]
self.context = context
self.hooks = hooks
self.type = question.get("type", "string")
self.default = question.get("default", None)
self.optional = question.get("optional", False)
self.visible = question.get("visible", None)
self.context = context
self.choices = question.get("choices", [])
self.pattern = question.get("pattern", self.pattern)
self.ask = question.get("ask", {"en": self.name})
@ -623,6 +679,8 @@ class Question(object):
self.current_value = question.get("current_value")
# .value is the "proposed" value which we got from the user
self.value = question.get("value")
# Use to return several values in case answer is in mutipart
self.values = {}
# Empty value is parsed as empty string
if self.default == "":
@ -663,8 +721,8 @@ class Question(object):
# - 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
self.value = self.values[self.name] = None
return self.values
for i in range(5):
# Display question if no value filled or if it's a readonly message
@ -698,9 +756,18 @@ class Question(object):
break
self.value = self._post_parse_value()
self.value = self.values[self.name] = self._post_parse_value()
<<<<<<< HEAD
return self.value
=======
# Search for post actions in hooks
post_hook = f"post_ask__{self.name}"
if post_hook in self.hooks:
self.values.update(self.hooks[post_hook](self))
return self.values
>>>>>>> c0cd8dbf... [enh] config panel python method hook
def _prevalidate(self):
if self.value in [None, ""] and not self.optional:
@ -864,8 +931,10 @@ class PasswordQuestion(Question):
default_value = ""
forbidden_chars = "{}"
def __init__(self, question, context: Mapping[str, Any] = {}):
super().__init__(question, context)
def __init__(self, question,
context: Mapping[str, Any] = {},
hooks: Dict[str, Callable] = {}):
super().__init__(question, context, hooks)
self.redact = True
if self.default is not None:
raise YunohostValidationError(
@ -983,8 +1052,9 @@ class BooleanQuestion(Question):
choices="yes/no",
)
def __init__(self, question, context: Mapping[str, Any] = {}):
super().__init__(question, context)
def __init__(self, question, context: Mapping[str, Any] = {},
hooks: Dict[str, Callable] = {}):
super().__init__(question, context, hooks)
self.yes = question.get("yes", 1)
self.no = question.get("no", 0)
if self.default is None:
@ -1004,10 +1074,11 @@ class BooleanQuestion(Question):
class DomainQuestion(Question):
argument_type = "domain"
def __init__(self, question, context: Mapping[str, Any] = {}):
def __init__(self, question, context: Mapping[str, Any] = {},
hooks: Dict[str, Callable] = {}):
from yunohost.domain import domain_list, _get_maindomain
super().__init__(question, context)
super().__init__(question, context, hooks)
if self.default is None:
self.default = _get_maindomain()
@ -1030,11 +1101,12 @@ class DomainQuestion(Question):
class UserQuestion(Question):
argument_type = "user"
def __init__(self, question, context: Mapping[str, Any] = {}):
def __init__(self, question, context: Mapping[str, Any] = {},
hooks: Dict[str, Callable] = {}):
from yunohost.user import user_list, user_info
from yunohost.domain import _get_maindomain
super().__init__(question, context)
super().__init__(question, context, hooks)
self.choices = list(user_list()["users"].keys())
if not self.choices:
@ -1056,8 +1128,9 @@ class NumberQuestion(Question):
argument_type = "number"
default_value = None
def __init__(self, question, context: Mapping[str, Any] = {}):
super().__init__(question, context)
def __init__(self, question, context: Mapping[str, Any] = {},
hooks: Dict[str, Callable] = {}):
super().__init__(question, context, hooks)
self.min = question.get("min", None)
self.max = question.get("max", None)
self.step = question.get("step", None)
@ -1108,8 +1181,9 @@ class DisplayTextQuestion(Question):
argument_type = "display_text"
readonly = True
def __init__(self, question, context: Mapping[str, Any] = {}):
super().__init__(question, context)
def __init__(self, question, context: Mapping[str, Any] = {},
hooks: Dict[str, Callable] = {}):
super().__init__(question, context, hooks)
self.optional = True
self.style = question.get(
@ -1143,8 +1217,9 @@ class FileQuestion(Question):
if os.path.exists(upload_dir):
shutil.rmtree(upload_dir)
def __init__(self, question, context: Mapping[str, Any] = {}):
super().__init__(question, context)
def __init__(self, question, context: Mapping[str, Any] = {},
hooks: Dict[str, Callable] = {}):
super().__init__(question, context, hooks)
self.accept = question.get("accept", "")
def _prevalidate(self):
@ -1214,7 +1289,14 @@ ARGUMENTS_TYPE_PARSERS = {
def ask_questions_and_parse_answers(
<<<<<<< HEAD
raw_questions: Dict, prefilled_answers: Union[str, Mapping[str, Any]] = {}
=======
raw_questions: Dict,
prefilled_answers: Union[str, Mapping[str, Any]] = {},
current_values: Union[str, Mapping[str, Any]] = {},
hooks: Dict[str, Callable[[], None]] = {}
>>>>>>> c0cd8dbf... [enh] config panel python method hook
) -> List[Question]:
"""Parse arguments store in either manifest.json or actions.json or from a
config panel against the user answers when they are present.
@ -1241,13 +1323,24 @@ def ask_questions_and_parse_answers(
else:
answers = {}
<<<<<<< HEAD
=======
context = {**current_values, **answers}
>>>>>>> c0cd8dbf... [enh] config panel python method hook
out = []
for raw_question in raw_questions:
question_class = ARGUMENTS_TYPE_PARSERS[raw_question.get("type", "string")]
raw_question["value"] = answers.get(raw_question["name"])
<<<<<<< HEAD
question = question_class(raw_question, context=answers)
answers[question.name] = question.ask_if_needed()
=======
question = question_class(raw_question, context=context, hooks=hooks)
new_values = question.ask_if_needed()
answers.update(new_values)
context.update(new_values)
>>>>>>> c0cd8dbf... [enh] config panel python method hook
out.append(question)
return out