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: arguments:
app: app:
help: App name help: App name
panel: key:
help: Select a specific panel help: Select a specific panel, section or a question
nargs: '?' nargs: '?'
-f: -f:
full: --full full: --full

View file

@ -96,51 +96,72 @@ ynh_value_set() {
_ynh_panel_get() { _ynh_panel_get() {
# From settings # From settings
local params_sources local lines
params_sources=`python3 << EOL lines=`python3 << EOL
import toml import toml
from collections import OrderedDict from collections import OrderedDict
with open("../config_panel.toml", "r") as f: with open("../config_panel.toml", "r") as f:
file_content = f.read() file_content = f.read()
loaded_toml = toml.loads(file_content, _dict=OrderedDict) loaded_toml = toml.loads(file_content, _dict=OrderedDict)
for panel_name,panel in loaded_toml.items(): 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(): for section_name, section in panel.items():
if isinstance(section, dict): if not isinstance(section, dict): continue
for name, param in section.items(): for name, param in section.items():
if isinstance(param, dict) and param.get('type', 'string') not in ['success', 'info', 'warning', 'danger', 'display_text', 'markdown']: if not isinstance(param, dict):
print("%s=%s" % (name, param.get('source', 'settings'))) continue
print(';'.join([
name,
param.get('type', 'string'),
param.get('source', 'settings' if param.get('type', 'string') != 'file' else '')
]))
EOL EOL
` `
for param_source in $params_sources for line in $lines
do do
local short_setting="$(echo $param_source | cut -d= -f1)" IFS=';' read short_setting type source <<< "$line"
local getter="get__${short_setting}" local getter="get__${short_setting}"
local source="$(echo $param_source | cut -d= -f2)"
sources[${short_setting}]="$source" sources[${short_setting}]="$source"
types[${short_setting}]="$type"
file_hash[${short_setting}]="" file_hash[${short_setting}]=""
formats[${short_setting}]=""
# Get value from getter if exists # Get value from getter if exists
if type -t $getter 2>/dev/null | grep -q '^function$' 2>/dev/null; then if type -t $getter 2>/dev/null | grep -q '^function$' 2>/dev/null; then
old[$short_setting]="$($getter)" 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 # 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 if [[ "$source" == "settings" ]] ; then
source=":/etc/yunohost/apps/$app/settings.yml" source=":/etc/yunohost/apps/$app/settings.yml"
fi fi
local source_key="$(echo "$source" | cut -d: -f1)" local source_key="$(echo "$source" | cut -d: -f1)"
source_key=${source_key:-$short_setting} source_key=${source_key:-$short_setting}
local source_file="$(echo "$source" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" 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}")" 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 fi
done done
@ -152,27 +173,42 @@ _ynh_panel_apply() {
do do
local setter="set__${short_setting}" local setter="set__${short_setting}"
local source="${sources[$short_setting]}" local source="${sources[$short_setting]}"
local type="${types[$short_setting]}"
if [ "${changed[$short_setting]}" == "true" ] ; then if [ "${changed[$short_setting]}" == "true" ] ; then
# Apply setter if exists # Apply setter if exists
if type -t $setter 2>/dev/null | grep -q '^function$' 2>/dev/null; then if type -t $setter 2>/dev/null | grep -q '^function$' 2>/dev/null; then
$setter $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 elif [[ "$source" == "settings" ]] ; then
ynh_app_setting_set $app $short_setting "${!short_setting}" ynh_app_setting_set $app $short_setting "${!short_setting}"
# Get value from a kind of key/value file # Save multiline text in a file
elif [[ "$source" == *":"* ]] elif [[ "$type" == "text" ]] ; then
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)" local source_key="$(echo "$source" | cut -d: -f1)"
source_key=${source_key:-$short_setting} source_key=${source_key:-$short_setting}
local source_file="$(echo "$source" | cut -d: -f2 | sed s@__FINALPATH__@$final_path@ | sed s/__APP__/$app/)" 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}" 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
fi fi
done done
@ -182,7 +218,13 @@ _ynh_panel_show() {
for short_setting in "${!old[@]}" for short_setting in "${!old[@]}"
do do
if [[ "${old[$short_setting]}" != YNH_NULL ]] ; then 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 fi
done done
} }
@ -225,7 +267,8 @@ _ynh_panel_validate() {
done done
if [[ "$is_error" == "true" ]] if [[ "$is_error" == "true" ]]
then then
ynh_die "Nothing has changed" ynh_print_info "Nothing has changed"
exit 0
fi fi
# Run validation if something is changed # Run validation if something is changed
@ -241,7 +284,7 @@ _ynh_panel_validate() {
if [ -n "$result" ] if [ -n "$result" ]
then then
local key="YNH_ERROR_${short_setting}" local key="YNH_ERROR_${short_setting}"
ynh_return "$key: $result" ynh_return "$key: \"$result\""
is_error=true is_error=true
fi fi
done done
@ -274,6 +317,9 @@ ynh_panel_run() {
declare -Ag changed=() declare -Ag changed=()
declare -Ag file_hash=() declare -Ag file_hash=()
declare -Ag sources=() declare -Ag sources=()
declare -Ag types=()
declare -Ag formats=()
case $1 in case $1 in
show) show)
ynh_panel_get ynh_panel_get
@ -281,12 +327,12 @@ ynh_panel_run() {
;; ;;
apply) apply)
max_progression=4 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_get
ynh_panel_validate 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_panel_apply
ynh_script_progression --message="Configuration of $app completed" --last ynh_script_progression --message="Configuration of $app completed" --last
;; ;;

View file

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