Multiline, file, tags management + prefilled cli

This commit is contained in:
ljf 2021-08-30 13:14:17 +02:00
parent bc725e9768
commit f1e5309d40
3 changed files with 177 additions and 86 deletions

View file

@ -838,8 +838,8 @@ app:
arguments:
app:
help: App name
panel:
help: Select a specific panel
key:
help: Select a specific panel, section or a question
nargs: '?'
-f:
full: --full

View file

@ -96,8 +96,8 @@ ynh_value_set() {
_ynh_panel_get() {
# From settings
local params_sources
params_sources=`python3 << EOL
local lines
lines=`python3 << EOL
import toml
from collections import OrderedDict
with open("../config_panel.toml", "r") as f:
@ -105,42 +105,63 @@ with open("../config_panel.toml", "r") as f:
loaded_toml = toml.loads(file_content, _dict=OrderedDict)
for panel_name, panel in loaded_toml.items():
if isinstance(panel, dict):
if not isinstance(panel, dict): continue
for section_name, section in panel.items():
if isinstance(section, dict):
if not isinstance(section, dict): continue
for name, param in section.items():
if isinstance(param, dict) and param.get('type', 'string') not in ['success', 'info', 'warning', 'danger', 'display_text', 'markdown']:
print("%s=%s" % (name, param.get('source', 'settings')))
if not isinstance(param, dict):
continue
print(';'.join([
name,
param.get('type', 'string'),
param.get('source', 'settings' if param.get('type', 'string') != 'file' else '')
]))
EOL
`
for param_source in $params_sources
for line in $lines
do
local short_setting="$(echo $param_source | cut -d= -f1)"
IFS=';' read short_setting type source <<< "$line"
local getter="get__${short_setting}"
local source="$(echo $param_source | cut -d= -f2)"
sources[${short_setting}]="$source"
types[${short_setting}]="$type"
file_hash[${short_setting}]=""
formats[${short_setting}]=""
# Get value from getter if exists
if type -t $getter 2>/dev/null | grep -q '^function$' 2>/dev/null; then
old[$short_setting]="$($getter)"
formats[${short_setting}]="yaml"
elif [[ "$source" == "" ]] ; then
old[$short_setting]="YNH_NULL"
# Get value from app settings or from another file
elif [[ "$source" == "settings" ]] || [[ "$source" == *":"* ]] ; then
elif [[ "$type" == "file" ]] ; then
if [[ "$source" == "settings" ]] ; then
ynh_die "File '${short_setting}' can't be stored in settings"
fi
old[$short_setting]="$(ls $(echo $source | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)"
file_hash[$short_setting]="true"
# Get multiline text from settings or from a full file
elif [[ "$type" == "text" ]] ; then
if [[ "$source" == "settings" ]] ; then
old[$short_setting]="$(ynh_app_setting_get $app $short_setting)"
elif [[ "$source" == *":"* ]] ; then
ynh_die "For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter"
else
old[$short_setting]="$(cat $(echo $source | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)"
fi
# Get value from a kind of key/value file
else
if [[ "$source" == "settings" ]] ; then
source=":/etc/yunohost/apps/$app/settings.yml"
fi
local source_key="$(echo "$source" | cut -d: -f1)"
source_key=${source_key:-$short_setting}
local source_file="$(echo "$source" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)"
old[$short_setting]="$(ynh_value_get --file="${source_file}" --key="${source_key}")"
# Specific case for files (all content of the file is the source)
else
old[$short_setting]="$(ls $(echo $source | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/) 2> /dev/null || echo YNH_NULL)"
file_hash[$short_setting]="true"
fi
done
@ -152,27 +173,42 @@ _ynh_panel_apply() {
do
local setter="set__${short_setting}"
local source="${sources[$short_setting]}"
local type="${types[$short_setting]}"
if [ "${changed[$short_setting]}" == "true" ] ; then
# Apply setter if exists
if type -t $setter 2>/dev/null | grep -q '^function$' 2>/dev/null; then
$setter
# Copy file in right place
elif [[ "$source" == "" ]] ; then
continue
# Save in a file
elif [[ "$type" == "file" ]] ; then
if [[ "$source" == "settings" ]] ; then
ynh_die "File '${short_setting}' can't be stored in settings"
fi
local source_file="$(echo "$source" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)"
cp "${!short_setting}" "$source_file"
# Save value in app settings
elif [[ "$source" == "settings" ]] ; then
ynh_app_setting_set $app $short_setting "${!short_setting}"
# Get value from a kind of key/value file
elif [[ "$source" == *":"* ]]
then
# Save multiline text in a file
elif [[ "$type" == "text" ]] ; then
if [[ "$source" == *":"* ]] ; then
ynh_die "For technical reasons, multiline text '${short_setting}' can't be stored automatically in a variable file, you have to create custom getter/setter"
fi
local source_file="$(echo "$source" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)"
echo "${!short_setting}" > "$source_file"
# Set value into a kind of key/value file
else
local source_key="$(echo "$source" | cut -d: -f1)"
source_key=${source_key:-$short_setting}
local source_file="$(echo "$source" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)"
ynh_value_set --file="${source_file}" --key="${source_key}" --value="${!short_setting}"
# Specific case for files (all content of the file is the source)
else
local source_file="$(echo "$source" | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)"
cp "${!short_setting}" "$source_file"
fi
fi
done
@ -182,7 +218,13 @@ _ynh_panel_show() {
for short_setting in "${!old[@]}"
do
if [[ "${old[$short_setting]}" != YNH_NULL ]] ; then
ynh_return "${short_setting}: \"${old[$short_setting]}\""
if [[ "${formats[$short_setting]}" == "yaml" ]] ; then
ynh_return "${short_setting}:"
ynh_return "$(echo "${old[$short_setting]}" | sed 's/^/ /g')"
else
ynh_return "${short_setting}: \"$(echo "${old[$short_setting]}" | sed ':a;N;$!ba;s/\n/\n\n/g')\""
fi
fi
done
}
@ -225,7 +267,8 @@ _ynh_panel_validate() {
done
if [[ "$is_error" == "true" ]]
then
ynh_die "Nothing has changed"
ynh_print_info "Nothing has changed"
exit 0
fi
# Run validation if something is changed
@ -241,7 +284,7 @@ _ynh_panel_validate() {
if [ -n "$result" ]
then
local key="YNH_ERROR_${short_setting}"
ynh_return "$key: $result"
ynh_return "$key: \"$result\""
is_error=true
fi
done
@ -274,6 +317,9 @@ ynh_panel_run() {
declare -Ag changed=()
declare -Ag file_hash=()
declare -Ag sources=()
declare -Ag types=()
declare -Ag formats=()
case $1 in
show)
ynh_panel_get
@ -281,12 +327,12 @@ ynh_panel_run() {
;;
apply)
max_progression=4
ynh_script_progression --message="Reading config panel description and current configuration..." --weight=1
ynh_script_progression --message="Reading config panel description and current configuration..."
ynh_panel_get
ynh_panel_validate
ynh_script_progression --message="Applying the new configuration..." --weight=1
ynh_script_progression --message="Applying the new configuration..."
ynh_panel_apply
ynh_script_progression --message="Configuration of $app completed" --last
;;

View file

@ -35,6 +35,7 @@ import glob
import urllib.parse
import base64
import tempfile
import readline
from collections import OrderedDict
from moulinette import msignals, m18n, msettings
@ -1754,36 +1755,21 @@ def app_action_run(operation_logger, app, action, args=None):
# * docstrings
# * merge translations on the json once the workflow is in place
@is_unit_operation()
def app_config_show(operation_logger, app, panel='', full=False):
def app_config_show(operation_logger, app, key='', full=False):
# logger.warning(m18n.n("experimental_feature"))
# Check app is installed
_assert_is_installed(app)
panel = panel if panel else ''
operation_logger.start()
key = key if key else ''
# Read config panel toml
config_panel = _get_app_config_panel(app, filter_key=panel)
config_panel = _get_app_hydrated_config_panel(operation_logger,
app, filter_key=key)
if not config_panel:
return None
# Call config script to extract current values
parsed_values = _call_config_script(operation_logger, app, 'show')
# # Check and transform values if needed
# options = [option for _, _, option in _get_options_iterator(config_panel)]
# args_dict = _parse_args_in_yunohost_format(
# parsed_values, options, False
# )
# Hydrate
logger.debug("Hydrating config with current value")
for _, _, option in _get_options_iterator(config_panel):
if option['name'] in parsed_values:
option["value"] = parsed_values[option['name']] #args_dict[option["name"]][0]
# Format result in full or reduce mode
if full:
operation_logger.success()
@ -1800,8 +1786,8 @@ def app_config_show(operation_logger, app, panel='', full=False):
}
if not option.get('optional', False):
r_option['ask'] += ' *'
if option.get('value', None) is not None:
r_option['value'] = option['value']
if option.get('current_value', None) is not None:
r_option['value'] = option['current_value']
operation_logger.success()
return result
@ -1812,7 +1798,6 @@ def app_config_get(operation_logger, app, key):
# Check app is installed
_assert_is_installed(app)
operation_logger.start()
# Read config panel toml
config_panel = _get_app_config_panel(app, filter_key=key)
@ -1820,6 +1805,8 @@ def app_config_get(operation_logger, app, key):
if not config_panel:
raise YunohostError("app_config_no_panel")
operation_logger.start()
# Call config script to extract current values
parsed_values = _call_config_script(operation_logger, app, 'show')
@ -1851,7 +1838,8 @@ def app_config_set(operation_logger, app, key=None, value=None, args=None):
filter_key = key if key else ''
# Read config panel toml
config_panel = _get_app_config_panel(app, filter_key=filter_key)
config_panel = _get_app_hydrated_config_panel(operation_logger,
app, filter_key=filter_key)
if not config_panel:
raise YunohostError("app_config_no_panel")
@ -1862,12 +1850,16 @@ def app_config_set(operation_logger, app, key=None, value=None, args=None):
operation_logger.start()
# Prepare pre answered questions
args = dict(urllib.parse.parse_qsl(args, keep_blank_values=True)) if args else {}
if args:
args = { key: ','.join(value) for key, value in urllib.parse.parse_qs(args, keep_blank_values=True).items() }
else:
args = {}
if value is not None:
args = {filter_key.split('.')[-1]: value}
try:
logger.debug("Asking unanswered question and prevalidating...")
args_dict = {}
for panel in config_panel.get("panel", []):
if msettings.get('interface') == 'cli' and len(filter_key.split('.')) < 3:
msignals.display(colorize("\n" + "=" * 40, 'purple'))
@ -1878,13 +1870,13 @@ def app_config_set(operation_logger, app, key=None, value=None, args=None):
msignals.display(colorize(f"\n# {section['name']}", 'purple'))
# Check and ask unanswered questions
args_dict = _parse_args_in_yunohost_format(
args_dict.update(_parse_args_in_yunohost_format(
args, section['options']
)
))
# Call config script to extract current values
logger.info("Running config script...")
env = {key: str(value[0]) for key, value in args_dict.items()}
env = {key: str(value[0]) for key, value in args_dict.items() if not value[0] is None}
errors = _call_config_script(operation_logger, app, 'apply', env=env)
# Script got manually interrupted ... N.B. : KeyboardInterrupt does not inherit from Exception
@ -2246,6 +2238,37 @@ def _get_app_config_panel(app_id, filter_key=''):
return None
def _get_app_hydrated_config_panel(operation_logger, app, filter_key=''):
# Read config panel toml
config_panel = _get_app_config_panel(app, filter_key=filter_key)
if not config_panel:
return None
operation_logger.start()
# Call config script to extract current values
parsed_values = _call_config_script(operation_logger, app, 'show')
# # Check and transform values if needed
# options = [option for _, _, option in _get_options_iterator(config_panel)]
# args_dict = _parse_args_in_yunohost_format(
# parsed_values, options, False
# )
# Hydrate
logger.debug("Hydrating config with current value")
for _, _, option in _get_options_iterator(config_panel):
if option['name'] in parsed_values:
value = parsed_values[option['name']]
if isinstance(value, dict):
option.update(value)
else:
option["current_value"] = value #args_dict[option["name"]][0]
return config_panel
def _get_app_settings(app_id):
"""
@ -2808,6 +2831,7 @@ class YunoHostArgumentFormatParser(object):
parsed_question.name = question["name"]
parsed_question.type = question.get("type", 'string')
parsed_question.default = question.get("default", None)
parsed_question.current_value = question.get("current_value")
parsed_question.optional = question.get("optional", False)
parsed_question.choices = question.get("choices", [])
parsed_question.pattern = question.get("pattern")
@ -2835,11 +2859,20 @@ class YunoHostArgumentFormatParser(object):
msignals.display(text_for_user_input_in_cli)
elif question.value is None:
prefill = None
if question.current_value is not None:
prefill = question.current_value
elif question.default is not None:
prefill = question.default
readline.set_startup_hook(lambda: readline.insert_text(prefill))
try:
question.value = msignals.prompt(
message=text_for_user_input_in_cli,
is_password=self.hide_user_input_in_prompt,
confirm=self.hide_user_input_in_prompt
)
finally:
readline.set_startup_hook()
# Apply default value
@ -2897,8 +2930,6 @@ class YunoHostArgumentFormatParser(object):
if question.choices:
text_for_user_input_in_cli += " [{0}]".format(" | ".join(question.choices))
if question.default is not None:
text_for_user_input_in_cli += " (default: {0})".format(question.default)
if question.help or question.helpLink:
text_for_user_input_in_cli += ":\033[m"
if question.help:
@ -2919,6 +2950,18 @@ class StringArgumentParser(YunoHostArgumentFormatParser):
default_value = ""
class TagsArgumentParser(YunoHostArgumentFormatParser):
argument_type = "tags"
def _prevalidate(self, question):
values = question.value
for value in values.split(','):
question.value = value
super()._prevalidate(question)
question.value = values
class PasswordArgumentParser(YunoHostArgumentFormatParser):
hide_user_input_in_prompt = True
argument_type = "password"
@ -2938,13 +2981,15 @@ class PasswordArgumentParser(YunoHostArgumentFormatParser):
return question
def _prevalidate(self, question):
super()._prevalidate(question)
if question.value is not None:
if any(char in question.value for char in self.forbidden_chars):
raise YunohostValidationError(
"pattern_password_app", forbidden_chars=self.forbidden_chars
)
# If it's an optional argument the value should be empty or strong enough
if not question.optional or question.value:
from yunohost.utils.password import assert_password_is_strong_enough
assert_password_is_strong_enough("user", question.value)
@ -3098,23 +3143,26 @@ class DisplayTextArgumentParser(YunoHostArgumentFormatParser):
readonly = True
def parse_question(self, question, user_answers):
question = super(DisplayTextArgumentParser, self).parse_question(
question_parsed = super().parse_question(
question, user_answers
)
question.optional = True
question_parsed.optional = True
question_parsed.style = question.get('style', 'info')
return question
return question_parsed
def _format_text_for_user_input_in_cli(self, question):
text = question.ask['en']
if question.type in ['info', 'warning', 'danger']:
if question.style in ['success', 'info', 'warning', 'danger']:
color = {
'success': 'green',
'info': 'cyan',
'warning': 'yellow',
'danger': 'red'
}
return colorize(m18n.g(question.type), color[question.type]) + f" {text}"
return colorize(m18n.g(question.style), color[question.style]) + f" {text}"
else:
return text
@ -3137,7 +3185,7 @@ class FileArgumentParser(YunoHostArgumentFormatParser):
if question.get('accept'):
question_parsed.accept = question.get('accept').replace(' ', '').split(',')
else:
question.accept = []
question_parsed.accept = []
if msettings.get('interface') == 'api':
if user_answers.get(question_parsed.name):
question_parsed.value = {
@ -3200,7 +3248,7 @@ ARGUMENTS_TYPE_PARSERS = {
"string": StringArgumentParser,
"text": StringArgumentParser,
"select": StringArgumentParser,
"tags": StringArgumentParser,
"tags": TagsArgumentParser,
"email": StringArgumentParser,
"url": StringArgumentParser,
"date": StringArgumentParser,
@ -3214,10 +3262,7 @@ ARGUMENTS_TYPE_PARSERS = {
"number": NumberArgumentParser,
"range": NumberArgumentParser,
"display_text": DisplayTextArgumentParser,
"success": DisplayTextArgumentParser,
"danger": DisplayTextArgumentParser,
"warning": DisplayTextArgumentParser,
"info": DisplayTextArgumentParser,
"alert": DisplayTextArgumentParser,
"markdown": DisplayTextArgumentParser,
"file": FileArgumentParser,
}