Merge pull request #1677 from YunoHost/pydantic

ConfigPanel: switch to pydantic 3/3
This commit is contained in:
Alexandre Aubin 2023-10-30 15:19:17 +01:00 committed by GitHub
commit 093c707eb6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 1946 additions and 1337 deletions

2
debian/control vendored
View file

@ -16,7 +16,7 @@ Depends: ${python3:Depends}, ${misc:Depends}
, python3-toml, python3-packaging, python3-publicsuffix2
, python3-ldap, python3-zeroconf (>= 0.47), python3-lexicon,
, python3-cryptography, python3-jwt
, python-is-python3
, python-is-python3, python3-pydantic, python3-email-validator
, nginx, nginx-extras (>=1.22)
, apt, apt-transport-https, apt-utils, dirmngr
, openssh-server, iptables, fail2ban, bind9-dnsutils

View file

@ -0,0 +1,4 @@
from yunohost.utils.configpanel import ConfigPanelModel
print(ConfigPanelModel.schema_json(indent=2))

View file

@ -26,7 +26,7 @@ import re
import subprocess
import tempfile
import copy
from typing import List, Tuple, Dict, Any, Iterator, Optional
from typing import TYPE_CHECKING, List, Tuple, Dict, Any, Iterator, Optional, Union
from packaging import version
from logging import getLogger
from pathlib import Path
@ -45,11 +45,12 @@ from moulinette.utils.filesystem import (
chmod,
)
from yunohost.utils.configpanel import ConfigPanel, ask_questions_and_parse_answers
from yunohost.utils.configpanel import ConfigPanel
from yunohost.utils.form import (
DomainOption,
WebPathOption,
hydrate_questions_with_choices,
ask_questions_and_parse_answers,
parse_raw_options,
)
from yunohost.utils.i18n import _value_for_locale
from yunohost.utils.error import YunohostError, YunohostValidationError
@ -71,6 +72,12 @@ from yunohost.app_catalog import ( # noqa
APPS_CATALOG_LOGOS,
)
if TYPE_CHECKING:
from pydantic.typing import AbstractSetIntStr, MappingIntStrAny
from yunohost.utils.configpanel import ConfigPanelModel, RawSettings
from yunohost.utils.form import FormModel
logger = getLogger("yunohost.app")
APPS_SETTING_PATH = "/etc/yunohost/apps/"
@ -121,8 +128,8 @@ def app_info(app, full=False, upgradable=False):
"""
Get info for a specific app
"""
from yunohost.permission import user_permission_list
from yunohost.domain import domain_config_get
from yunohost.permission import user_permission_list
_assert_is_installed(app)
@ -955,8 +962,7 @@ def app_upgrade(
def app_manifest(app, with_screenshot=False):
manifest, extracted_app_folder = _extract_app(app)
raw_questions = manifest.get("install", {}).values()
manifest["install"] = hydrate_questions_with_choices(raw_questions)
manifest["install"] = parse_raw_options(manifest.get("install", {}), serialize=True)
# Add a base64 image to be displayed in web-admin
if with_screenshot and Moulinette.interface.type == "api":
@ -1098,13 +1104,9 @@ def app_install(
app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name)
# Retrieve arguments list for install script
raw_questions = manifest["install"]
questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args)
args = {
question.id: question.value
for question in questions
if not question.readonly and question.value is not None
}
raw_options = manifest["install"]
options, form = ask_questions_and_parse_answers(raw_options, prefilled_answers=args)
args = form.dict(exclude_none=True)
# Validate domain / path availability for webapps
# (ideally this should be handled by the resource system for manifest v >= 2
@ -1141,15 +1143,15 @@ def app_install(
"current_revision": manifest.get("remote", {}).get("revision", "?"),
}
# If packaging_format v2+, save all install questions as settings
# If packaging_format v2+, save all install options as settings
if packaging_format >= 2:
for question in questions:
for option in options:
# Except user-provider passwords
# ... which we need to reinject later in the env_dict
if question.type == "password":
if option.type == "password":
continue
app_settings[question.id] = question.value
app_settings[option.id] = form[option.id]
_set_app_settings(app_instance_name, app_settings)
@ -1202,23 +1204,23 @@ def app_install(
app_instance_name, args=args, workdir=extracted_app_folder, action="install"
)
# If packaging_format v2+, save all install questions as settings
# If packaging_format v2+, save all install options as settings
if packaging_format >= 2:
for question in questions:
for option in options:
# Reinject user-provider passwords which are not in the app settings
# (cf a few line before)
if question.type == "password":
env_dict[question.id] = question.value
if option.type == "password":
env_dict[option.id] = form[option.id]
# We want to hav the env_dict in the log ... but not password values
env_dict_for_logging = env_dict.copy()
for question in questions:
# Or should it be more generally question.redact ?
if question.type == "password":
if f"YNH_APP_ARG_{question.id.upper()}" in env_dict_for_logging:
del env_dict_for_logging[f"YNH_APP_ARG_{question.id.upper()}"]
if question.id in env_dict_for_logging:
del env_dict_for_logging[question.id]
for option in options:
# Or should it be more generally option.redact ?
if option.type == "password":
if f"YNH_APP_ARG_{option.id.upper()}" in env_dict_for_logging:
del env_dict_for_logging[f"YNH_APP_ARG_{option.id.upper()}"]
if option.id in env_dict_for_logging:
del env_dict_for_logging[option.id]
operation_logger.extra.update({"env": env_dict_for_logging})
@ -1801,30 +1803,39 @@ class AppConfigPanel(ConfigPanel):
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.toml")
settings_must_be_defined: bool = True
def _run_action(self, action):
env = {key: str(value) for key, value in self.new_values.items()}
self._call_config_script(action, env=env)
def _get_raw_settings(self, config: "ConfigPanelModel") -> "RawSettings":
return self._call_config_script("show")
def _get_raw_settings(self):
self.values = self._call_config_script("show")
def _apply(self):
env = {key: str(value) for key, value in self.new_values.items()}
def _apply(
self,
form: "FormModel",
previous_settings: dict[str, Any],
exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None,
) -> None:
env = {key: str(value) for key, value in form.dict().items()}
return_content = self._call_config_script("apply", env=env)
# If the script returned validation error
# raise a ValidationError exception using
# the first key
if return_content:
for key, message in return_content.get("validation_errors").items():
errors = return_content.get("validation_errors")
if errors:
for key, message in errors.items():
raise YunohostValidationError(
"app_argument_invalid",
name=key,
error=message,
)
def _call_config_script(self, action, env=None):
def _run_action(self, form: "FormModel", action_id: str) -> None:
env = {key: str(value) for key, value in form.dict().items()}
self._call_config_script(action_id, env=env)
def _call_config_script(
self, action: str, env: Union[dict[str, Any], None] = None
) -> dict[str, Any]:
from yunohost.hook import hook_exec
if env is None:

View file

@ -502,11 +502,26 @@ def _get_relative_name_for_dns_zone(domain, base_dns_zone):
def _get_registrar_config_section(domain):
from lexicon.providers.auto import _relevant_provider_for_domain
registrar_infos = {
registrar_infos = OrderedDict(
{
"name": m18n.n(
"registrar_infos"
), # This is meant to name the config panel section, for proper display in the webadmin
"registrar": OrderedDict(
{
"readonly": True,
"visible": False,
"default": None,
}
),
"infos": OrderedDict(
{
"type": "alert",
"style": "info",
}
),
}
)
dns_zone = _get_dns_zone_for_domain(domain)
@ -519,61 +534,35 @@ def _get_registrar_config_section(domain):
else:
parent_domain_link = parent_domain
registrar_infos["registrar"] = OrderedDict(
{
"type": "alert",
"style": "info",
"ask": m18n.n(
registrar_infos["registrar"]["default"] = "parent_domain"
registrar_infos["infos"]["ask"] = m18n.n(
"domain_dns_registrar_managed_in_parent_domain",
parent_domain=parent_domain,
parent_domain_link=parent_domain_link,
),
"value": "parent_domain",
}
)
return OrderedDict(registrar_infos)
return registrar_infos
# TODO big project, integrate yunohost's dynette as a registrar-like provider
# TODO big project, integrate other dyndns providers such as netlib.re, or cf the list of dyndns providers supported by cloudron...
if is_yunohost_dyndns_domain(dns_zone):
registrar_infos["registrar"] = OrderedDict(
{
"type": "alert",
"style": "success",
"ask": m18n.n("domain_dns_registrar_yunohost"),
"value": "yunohost",
}
)
return OrderedDict(registrar_infos)
registrar_infos["registrar"]["default"] = "yunohost"
registrar_infos["infos"]["style"] = "success"
registrar_infos["infos"]["ask"] = m18n.n("domain_dns_registrar_yunohost")
return registrar_infos
elif is_special_use_tld(dns_zone):
registrar_infos["registrar"] = OrderedDict(
{
"type": "alert",
"style": "info",
"ask": m18n.n("domain_dns_conf_special_use_tld"),
"value": None,
}
)
registrar_infos["infos"]["ask"] = m18n.n("domain_dns_conf_special_use_tld")
try:
registrar = _relevant_provider_for_domain(dns_zone)[0]
except ValueError:
registrar_infos["registrar"] = OrderedDict(
{
"type": "alert",
"style": "warning",
"ask": m18n.n("domain_dns_registrar_not_supported"),
"value": None,
}
)
registrar_infos["registrar"]["default"] = None
registrar_infos["infos"]["ask"] = m18n.n("domain_dns_registrar_not_supported")
registrar_infos["infos"]["style"] = "warning"
else:
registrar_infos["registrar"] = OrderedDict(
{
"type": "alert",
"style": "info",
"ask": m18n.n("domain_dns_registrar_supported", registrar=registrar),
"value": registrar,
}
registrar_infos["registrar"]["default"] = registrar
registrar_infos["infos"]["ask"] = m18n.n(
"domain_dns_registrar_supported", registrar=registrar
)
TESTED_REGISTRARS = ["ovh", "gandi"]
@ -601,7 +590,7 @@ def _get_registrar_config_section(domain):
infos["optional"] = infos.get("optional", "False")
registrar_infos.update(registrar_credentials)
return OrderedDict(registrar_infos)
return registrar_infos
def _get_registar_settings(domain):

View file

@ -19,7 +19,7 @@
import os
import time
from pathlib import Path
from typing import List, Optional
from typing import TYPE_CHECKING, Any, List, Optional, Union
from collections import OrderedDict
from logging import getLogger
@ -47,6 +47,12 @@ from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.dns import is_yunohost_dyndns_domain
from yunohost.log import is_unit_operation
if TYPE_CHECKING:
from pydantic.typing import AbstractSetIntStr, MappingIntStrAny
from yunohost.utils.configpanel import RawConfig
from yunohost.utils.form import FormModel
logger = getLogger("yunohost.domain")
DOMAIN_SETTINGS_DIR = "/etc/yunohost/domains"
@ -649,98 +655,83 @@ class DomainConfigPanel(ConfigPanel):
save_path_tpl = f"{DOMAIN_SETTINGS_DIR}/{{entity}}.yml"
save_mode = "diff"
def get(self, key="", mode="classic"):
result = super().get(key=key, mode=mode)
if mode == "full":
for panel, section, option in self._iterate():
# This injects:
# i18n: domain_config_cert_renew_help
# i18n: domain_config_default_app_help
# i18n: domain_config_xmpp_help
if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
option["help"] = m18n.n(
self.config["i18n"] + "_" + option["id"] + "_help"
)
return self.config
return result
def _get_raw_config(self) -> "RawConfig":
# TODO add mechanism to share some settings with other domains on the same zone
raw_config = super()._get_raw_config()
def _get_raw_config(self):
toml = super()._get_raw_config()
any_filter = all(self.filter_key)
panel_id, section_id, option_id = self.filter_key
toml["feature"]["xmpp"]["xmpp"]["default"] = (
raw_config["feature"]["xmpp"]["xmpp"]["default"] = (
1 if self.entity == _get_maindomain() else 0
)
# Portal settings are only available on "topest" domains
if _get_parent_domain_of(self.entity, topest=True) is not None:
del toml["feature"]["portal"]
del raw_config["feature"]["portal"]
# Optimize wether or not to load the DNS section,
# e.g. we don't want to trigger the whole _get_registary_config_section
# when just getting the current value from the feature section
filter_key = self.filter_key.split(".") if self.filter_key != "" else []
if not filter_key or filter_key[0] == "dns":
if not any_filter or panel_id == "dns":
from yunohost.dns import _get_registrar_config_section
toml["dns"]["registrar"] = _get_registrar_config_section(self.entity)
# FIXME: Ugly hack to save the registar id/value and reinject it in _get_raw_settings ...
self.registar_id = toml["dns"]["registrar"]["registrar"]["value"]
del toml["dns"]["registrar"]["registrar"]["value"]
raw_config["dns"]["registrar"] = _get_registrar_config_section(self.entity)
# Cert stuff
if not filter_key or filter_key[0] == "cert":
if not any_filter or panel_id == "cert":
from yunohost.certificate import certificate_status
status = certificate_status([self.entity], full=True)["certificates"][
self.entity
]
toml["cert"]["cert"]["cert_summary"]["style"] = status["style"]
raw_config["cert"]["cert"]["cert_summary"]["style"] = status["style"]
# i18n: domain_config_cert_summary_expired
# i18n: domain_config_cert_summary_selfsigned
# i18n: domain_config_cert_summary_abouttoexpire
# i18n: domain_config_cert_summary_ok
# i18n: domain_config_cert_summary_letsencrypt
toml["cert"]["cert"]["cert_summary"]["ask"] = m18n.n(
raw_config["cert"]["cert"]["cert_summary"]["ask"] = m18n.n(
f"domain_config_cert_summary_{status['summary']}"
)
# FIXME: Ugly hack to save the cert status and reinject it in _get_raw_settings ...
self.cert_status = status
for option_id, status_key in [
("cert_validity", "validity"),
("cert_issuer", "CA_type"),
("acme_eligible", "ACME_eligible"),
# FIXME not sure why "summary" was injected in settings values
# ("summary", "summary")
]:
raw_config["cert"]["cert"][option_id]["default"] = status[status_key]
return toml
# Other specific strings used in config panels
# i18n: domain_config_cert_renew_help
def _get_raw_settings(self):
# TODO add mechanism to share some settings with other domains on the same zone
super()._get_raw_settings()
return raw_config
# FIXME: Ugly hack to save the registar id/value and reinject it in _get_raw_settings ...
filter_key = self.filter_key.split(".") if self.filter_key != "" else []
if not filter_key or filter_key[0] == "dns":
self.values["registrar"] = self.registar_id
def _apply(
self,
form: "FormModel",
previous_settings: dict[str, Any],
exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None,
) -> None:
next_settings = {
k: v for k, v in form.dict().items() if previous_settings.get(k) != v
}
# FIXME: Ugly hack to save the cert status and reinject it in _get_raw_settings ...
if not filter_key or filter_key[0] == "cert":
self.values["cert_validity"] = self.cert_status["validity"]
self.values["cert_issuer"] = self.cert_status["CA_type"]
self.values["acme_eligible"] = self.cert_status["ACME_eligible"]
self.values["summary"] = self.cert_status["summary"]
def _apply(self):
if (
"default_app" in self.future_values
and self.future_values["default_app"] != self.values["default_app"]
):
if "default_app" in next_settings:
from yunohost.app import app_ssowatconf, app_map
if "/" in app_map(raw=True).get(self.entity, {}):
raise YunohostValidationError(
"app_make_default_location_already_used",
app=self.future_values["default_app"],
app=next_settings["default_app"],
domain=self.entity,
other_app=app_map(raw=True)[self.entity]["/"]["id"],
)
@ -753,8 +744,7 @@ class DomainConfigPanel(ConfigPanel):
"portal_theme",
]
if _get_parent_domain_of(self.entity, topest=True) is None and any(
option in self.future_values
and self.new_values[option] != self.values.get(option)
option in next_settings
for option in portal_options
):
from yunohost.portal import PORTAL_SETTINGS_DIR
@ -762,12 +752,11 @@ class DomainConfigPanel(ConfigPanel):
# Portal options are also saved in a `domain.portal.yml` file
# that can be read by the portal API.
# FIXME remove those from the config panel saved values?
portal_values = {
option: self.future_values[option] for option in portal_options
}
portal_values = form.dict(include=set(portal_options))
portal_settings_path = Path(f"{PORTAL_SETTINGS_DIR}/{self.entity}.json")
portal_settings = {"apps": {}}
portal_settings: dict[str, Any] = {"apps": {}}
if portal_settings_path.exists():
portal_settings.update(read_json(str(portal_settings_path)))
@ -778,38 +767,21 @@ class DomainConfigPanel(ConfigPanel):
str(portal_settings_path), portal_settings, sort_keys=True, indent=4
)
super()._apply()
super()._apply(form, previous_settings)
# Reload ssowat if default app changed
if (
"default_app" in self.future_values
and self.future_values["default_app"] != self.values["default_app"]
):
if "default_app" in next_settings:
app_ssowatconf()
stuff_to_regen_conf = []
if (
"xmpp" in self.future_values
and self.future_values["xmpp"] != self.values["xmpp"]
):
stuff_to_regen_conf.append("nginx")
stuff_to_regen_conf.append("metronome")
stuff_to_regen_conf = set()
if "xmpp" in next_settings:
stuff_to_regen_conf.update({"nginx", "metronome"})
if (
"mail_in" in self.future_values
and self.future_values["mail_in"] != self.values["mail_in"]
) or (
"mail_out" in self.future_values
and self.future_values["mail_out"] != self.values["mail_out"]
):
if "nginx" not in stuff_to_regen_conf:
stuff_to_regen_conf.append("nginx")
stuff_to_regen_conf.append("postfix")
stuff_to_regen_conf.append("dovecot")
stuff_to_regen_conf.append("rspamd")
if "mail_in" in next_settings or "mail_out" in next_settings:
stuff_to_regen_conf.update({"nginx", "postfix", "dovecot", "rspamd"})
if stuff_to_regen_conf:
regen_conf(names=stuff_to_regen_conf)
regen_conf(names=list(stuff_to_regen_conf))
def domain_action_run(domain, action, args=None):

View file

@ -469,7 +469,7 @@ class OperationLogger:
This class record logs and metadata like context or start time/end time.
"""
_instances: List[object] = []
_instances: List["OperationLogger"] = []
def __init__(self, operation, related_to=None, **kwargs):
# TODO add a way to not save password on app installation

View file

@ -19,16 +19,30 @@
import os
import subprocess
from logging import getLogger
from typing import TYPE_CHECKING, Any, Union
from moulinette import m18n
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.configpanel import ConfigPanel
from yunohost.utils.configpanel import ConfigPanel, parse_filter_key
from yunohost.utils.form import BaseOption
from yunohost.regenconf import regen_conf
from yunohost.firewall import firewall_reload
from yunohost.log import is_unit_operation
from yunohost.utils.legacy import translate_legacy_settings_to_configpanel_settings
if TYPE_CHECKING:
from yunohost.log import OperationLogger
from pydantic.typing import AbstractSetIntStr, MappingIntStrAny
from yunohost.utils.configpanel import (
ConfigPanelGetMode,
ConfigPanelModel,
RawConfig,
RawSettings,
)
from yunohost.utils.form import FormModel
logger = getLogger("yunohost.settings")
SETTINGS_PATH = "/etc/yunohost/settings.yml"
@ -120,47 +134,49 @@ class SettingsConfigPanel(ConfigPanel):
entity_type = "global"
save_path_tpl = SETTINGS_PATH
save_mode = "diff"
virtual_settings = ["root_password", "root_password_confirm", "passwordless_sudo"]
virtual_settings = {"root_password", "root_password_confirm", "passwordless_sudo"}
def __init__(self, config_path=None, save_path=None, creation=False):
def __init__(self, config_path=None, save_path=None, creation=False) -> None:
super().__init__("settings")
def get(self, key="", mode="classic"):
def get(
self, key: Union[str, None] = None, mode: "ConfigPanelGetMode" = "classic"
) -> Any:
result = super().get(key=key, mode=mode)
if mode == "full":
for panel, section, option in self._iterate():
if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
option["help"] = m18n.n(
self.config["i18n"] + "_" + option["id"] + "_help"
)
return self.config
# Dirty hack to let settings_get() to work from a python script
if isinstance(result, str) and result in ["True", "False"]:
result = bool(result == "True")
return result
def reset(self, key="", operation_logger=None):
self.filter_key = key
def reset(
self,
key: Union[str, None] = None,
operation_logger: Union["OperationLogger", None] = None,
) -> None:
self.filter_key = parse_filter_key(key)
# Read config panel toml
self._get_config_panel()
self.config, self.form = self._get_config_panel(prevalidate=True)
if not self.config:
raise YunohostValidationError("config_no_panel")
# FIXME find a better way to exclude previous settings
previous_settings = self.form.dict()
# Replace all values with default values
self.values = self._get_default_values()
for option in self.config.options:
if not option.readonly and (
option.optional or option.default not in {None, ""}
):
# FIXME Mypy complains about option.default not being a valid type for normalize but this should be ok
self.form[option.id] = option.normalize(option.default, option) # type: ignore
BaseOption.operation_logger = operation_logger
# FIXME Not sure if this is need (redact call to operation logger does it on all the instances)
# BaseOption.operation_logger = operation_logger
if operation_logger:
operation_logger.start()
try:
self._apply()
self._apply(self.form, previous_settings)
except YunohostError:
raise
# Script got manually interrupted ...
@ -178,10 +194,12 @@ class SettingsConfigPanel(ConfigPanel):
raise
logger.success(m18n.n("global_settings_reset_success"))
if operation_logger:
operation_logger.success()
def _get_raw_config(self):
toml = super()._get_raw_config()
def _get_raw_config(self) -> "RawConfig":
raw_config = super()._get_raw_config()
# Dynamic choice list for portal themes
THEMEDIR = "/usr/share/ssowat/portal/assets/themes/"
@ -189,42 +207,40 @@ class SettingsConfigPanel(ConfigPanel):
themes = [d for d in os.listdir(THEMEDIR) if os.path.isdir(THEMEDIR + d)]
except Exception:
themes = ["unsplash", "vapor", "light", "default", "clouds"]
toml["misc"]["portal"]["portal_theme"]["choices"] = themes
raw_config["misc"]["portal"]["portal_theme"]["choices"] = themes
return toml
return raw_config
def _get_raw_settings(self):
super()._get_raw_settings()
def _get_raw_settings(self, config: "ConfigPanelModel") -> "RawSettings":
raw_settings = super()._get_raw_settings(config)
# Specific logic for those settings who are "virtual" settings
# and only meant to have a custom setter mapped to tools_rootpw
self.values["root_password"] = ""
self.values["root_password_confirm"] = ""
raw_settings["root_password"] = ""
raw_settings["root_password_confirm"] = ""
# Specific logic for virtual setting "passwordless_sudo"
try:
from yunohost.utils.ldap import _get_ldap_interface
ldap = _get_ldap_interface()
self.values["passwordless_sudo"] = "!authenticate" in ldap.search(
raw_settings["passwordless_sudo"] = "!authenticate" in ldap.search(
"ou=sudo", "cn=admins", ["sudoOption"]
)[0].get("sudoOption", [])
except Exception:
self.values["passwordless_sudo"] = False
raw_settings["passwordless_sudo"] = False
def _apply(self):
root_password = self.new_values.pop("root_password", None)
root_password_confirm = self.new_values.pop("root_password_confirm", None)
passwordless_sudo = self.new_values.pop("passwordless_sudo", None)
return raw_settings
self.values = {
k: v for k, v in self.values.items() if k not in self.virtual_settings
}
self.new_values = {
k: v for k, v in self.new_values.items() if k not in self.virtual_settings
}
assert all(v not in self.future_values for v in self.virtual_settings)
def _apply(
self,
form: "FormModel",
previous_settings: dict[str, Any],
exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None,
) -> None:
root_password = form.get("root_password", None)
root_password_confirm = form.get("root_password_confirm", None)
passwordless_sudo = form.get("passwordless_sudo", None)
if root_password and root_password.strip():
if root_password != root_password_confirm:
@ -243,15 +259,20 @@ class SettingsConfigPanel(ConfigPanel):
{"sudoOption": ["!authenticate"] if passwordless_sudo else []},
)
super()._apply()
settings = {
k: v for k, v in self.future_values.items() if self.values.get(k) != v
# First save settings except virtual + default ones
super()._apply(form, previous_settings, exclude=self.virtual_settings)
next_settings = {
k: v
for k, v in form.dict(exclude=self.virtual_settings).items()
if previous_settings.get(k) != v
}
for setting_name, value in settings.items():
for setting_name, value in next_settings.items():
try:
# FIXME not sure to understand why we need the previous value if
# updated_settings has already been filtered
trigger_post_change_hook(
setting_name, self.values.get(setting_name), value
setting_name, previous_settings.get(setting_name), value
)
except Exception as e:
logger.error(f"Post-change hook for setting failed : {e}")

View file

@ -125,9 +125,9 @@ def test_app_config_get_nonexistentstuff(config_app):
with pytest.raises(YunohostValidationError):
app_config_get(config_app, "main.components.nonexistent")
app_setting(config_app, "boolean", delete=True)
app_setting(config_app, "number", delete=True)
with pytest.raises(YunohostError):
app_config_get(config_app, "main.components.boolean")
app_config_get(config_app, "main.components.number")
def test_app_config_regular_setting(config_app):

View file

@ -49,19 +49,19 @@ def test_registrar_list_integrity():
def test_magic_guess_registrar_weird_domain():
assert _get_registrar_config_section("yolo.tld")["registrar"]["value"] is None
assert _get_registrar_config_section("yolo.tld")["registrar"]["default"] is None
def test_magic_guess_registrar_ovh():
assert (
_get_registrar_config_section("yolo.yunohost.org")["registrar"]["value"]
_get_registrar_config_section("yolo.yunohost.org")["registrar"]["default"]
== "ovh"
)
def test_magic_guess_registrar_yunodyndns():
assert (
_get_registrar_config_section("yolo.nohost.me")["registrar"]["value"]
_get_registrar_config_section("yolo.nohost.me")["registrar"]["default"]
== "yunohost"
)

View file

@ -11,22 +11,23 @@ from typing import Any, Literal, Sequence, TypedDict, Union
from _pytest.mark.structures import ParameterSet
from moulinette import Moulinette
from yunohost import app, domain, user
from yunohost.utils.form import (
OPTIONS,
FORBIDDEN_PASSWORD_CHARS,
READONLY_TYPES,
ask_questions_and_parse_answers,
BaseChoicesOption,
BaseInputOption,
BaseReadonlyOption,
PasswordOption,
DomainOption,
WebPathOption,
BooleanOption,
FileOption,
evaluate_simple_js_expression,
)
from yunohost.utils import form
from yunohost.utils.error import YunohostError, YunohostValidationError
@ -95,6 +96,12 @@ def patch_with_tty():
yield
@pytest.fixture
def patch_cli_retries():
with patch.object(form, "MAX_RETRIES", 0):
yield
# ╭───────────────────────────────────────────────────────╮
# │ ╭─╴╭─╴┌─╴╭╮╷╭─┐┌─╮╶┬╴╭─╮╭─╴ │
# │ ╰─╮│ ├─╴│││├─┤├┬╯ │ │ │╰─╮ │
@ -378,8 +385,8 @@ def _fill_or_prompt_one_option(raw_option, intake):
options = {id_: raw_option}
answers = {id_: intake} if intake is not None else {}
option = ask_questions_and_parse_answers(options, answers)[0]
return (option, option.value if isinstance(option, BaseInputOption) else None)
options, form = ask_questions_and_parse_answers(options, answers)
return (options[0], form[id_] if isinstance(options[0], BaseInputOption) else None)
def _test_value_is_expected_output(value, expected_output):
@ -406,6 +413,7 @@ def _test_intake_may_fail(raw_option, intake, expected_output):
_test_intake(raw_option, intake, expected_output)
@pytest.mark.usefixtures("patch_cli_retries") # To avoid chain error logging
class BaseTest:
raw_option: dict[str, Any] = {}
prefill: dict[Literal["raw_option", "prefill", "intake"], Any]
@ -436,6 +444,13 @@ class BaseTest:
@classmethod
def _test_basic_attrs(self):
raw_option = self.get_raw_option(optional=True)
if raw_option["type"] in READONLY_TYPES:
del raw_option["optional"]
if raw_option["type"] == "select":
raw_option["choices"] = ["one"]
id_ = raw_option["id"]
option, value = _fill_or_prompt_one_option(raw_option, None)
@ -444,7 +459,7 @@ class BaseTest:
assert isinstance(option, OPTIONS[raw_option["type"]])
assert option.type == raw_option["type"]
assert option.id == id_
assert option.ask == {"en": id_}
assert option.ask == id_
assert option.readonly is (True if is_special_readonly_option else False)
assert option.visible is True
# assert option.bind is None
@ -481,6 +496,7 @@ class BaseTest:
base_raw_option = prefill_data["raw_option"]
prefill = prefill_data["prefill"]
# FIXME could patch prompt with prefill if we switch to "do not apply default if value is None|''"
with patch_prompt("") as prompt:
raw_option = self.get_raw_option(
raw_option=base_raw_option,
@ -489,7 +505,7 @@ class BaseTest:
)
option, value = _fill_or_prompt_one_option(raw_option, None)
expected_message = option.ask["en"]
expected_message = option.ask
choices = []
if isinstance(option, BaseChoicesOption):
@ -506,7 +522,7 @@ class BaseTest:
prefill=prefill,
is_multiline=option.type == "text",
autocomplete=choices,
help=option.help["en"],
help=option.help,
)
def test_scenarios(self, intake, expected_output, raw_option, data):
@ -551,10 +567,10 @@ class TestDisplayText(BaseTest):
ask_questions_and_parse_answers({_id: raw_option}, answers)
else:
with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
options = ask_questions_and_parse_answers(
options, form = ask_questions_and_parse_answers(
{_id: raw_option}, answers
)
assert stdout.getvalue() == f"{options[0].ask['en']}\n"
assert stdout.getvalue() == f"{options[0].ask}\n"
# ╭───────────────────────────────────────────────────────╮
@ -583,9 +599,7 @@ class TestAlert(TestDisplayText):
(None, None, {"ask": "Some text\na new line"}),
(None, None, {"ask": {"en": "Some text\na new line", "fr": "Un peu de texte\nune nouvelle ligne"}}),
*[(None, None, {"ask": "question", "style": style}) for style in ("success", "info", "warning", "danger")],
*xpass(scenarios=[
(None, None, {"ask": "question", "style": "nimp"}),
], reason="Should fail, wrong style"),
(None, YunohostError, {"ask": "question", "style": "nimp"}),
]
# fmt: on
@ -604,10 +618,10 @@ class TestAlert(TestDisplayText):
)
else:
with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
options = ask_questions_and_parse_answers(
options, form = ask_questions_and_parse_answers(
{"display_text_id": raw_option}, answers
)
ask = options[0].ask["en"]
ask = options[0].ask
if style in colors:
color = colors[style]
title = style.title() + (":" if style != "success" else "!")
@ -643,11 +657,15 @@ class TestString(BaseTest):
scenarios = [
*nones(None, "", output=""),
# basic typed values
*unchanged(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}), # FIXME should output as str?
(False, "False"),
(True, "True"),
(0, "0"),
(1, "1"),
(-1, "-1"),
(1337, "1337"),
(13.37, "13.37"),
*all_fails([], ["one"], {}),
*unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
*xpass(scenarios=[
([], []),
], reason="Should fail"),
# test strip
("value", "value"),
("value\n", "value"),
@ -660,7 +678,7 @@ class TestString(BaseTest):
(" ##value \n \tvalue\n ", "##value \n \tvalue"),
], reason=r"should fail or without `\n`?"),
# readonly
("overwrite", "expected value", {"readonly": True, "current_value": "expected value"}),
("overwrite", "expected value", {"readonly": True, "default": "expected value"}), # FIXME do we want to fail instead?
]
# fmt: on
@ -680,11 +698,15 @@ class TestText(BaseTest):
scenarios = [
*nones(None, "", output=""),
# basic typed values
*unchanged(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}), # FIXME should fail or output as str?
(False, "False"),
(True, "True"),
(0, "0"),
(1, "1"),
(-1, "-1"),
(1337, "1337"),
(13.37, "13.37"),
*all_fails([], ["one"], {}),
*unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
*xpass(scenarios=[
([], [])
], reason="Should fail"),
("value", "value"),
("value\n value", "value\n value"),
# test no strip
@ -697,7 +719,7 @@ class TestText(BaseTest):
(r" ##value \n \tvalue\n ", r"##value \n \tvalue\n"),
], reason="Should not be stripped"),
# readonly
("overwrite", "expected value", {"readonly": True, "current_value": "expected value"}),
("overwrite", "expected value", {"readonly": True, "default": "expected value"}),
]
# fmt: on
@ -715,11 +737,11 @@ class TestPassword(BaseTest):
}
# fmt: off
scenarios = [
*all_fails(False, True, 0, 1, -1, 1337, 13.37, raw_option={"optional": True}, error=TypeError), # FIXME those fails with TypeError
*all_fails(False, True, 0, 1, -1, 1337, 13.37, raw_option={"optional": True}),
*all_fails([], ["one"], {}, raw_option={"optional": True}, error=AttributeError), # FIXME those fails with AttributeError
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
*nones(None, "", output=""),
("s3cr3t!!", FAIL, {"default": "SUPAs3cr3t!!"}), # default is forbidden
("s3cr3t!!", YunohostError, {"default": "SUPAs3cr3t!!"}), # default is forbidden
*xpass(scenarios=[
("s3cr3t!!", "s3cr3t!!", {"example": "SUPAs3cr3t!!"}), # example is forbidden
], reason="Should fail; example is forbidden"),
@ -729,9 +751,9 @@ class TestPassword(BaseTest):
], reason="Should output exactly the same"),
("s3cr3t!!", "s3cr3t!!"),
("secret", FAIL),
*[("supersecret" + char, FAIL) for char in PasswordOption.forbidden_chars], # FIXME maybe add ` \n` to the list?
*[("supersecret" + char, FAIL) for char in FORBIDDEN_PASSWORD_CHARS], # FIXME maybe add ` \n` to the list?
# readonly
("s3cr3t!!", YunohostError, {"readonly": True, "current_value": "isforbidden"}), # readonly is forbidden
("s3cr3t!!", YunohostError, {"readonly": True}), # readonly is forbidden
]
# fmt: on
@ -744,35 +766,31 @@ class TestPassword(BaseTest):
class TestColor(BaseTest):
raw_option = {"type": "color", "id": "color_id"}
prefill = {
"raw_option": {"default": "#ff0000"},
"prefill": "#ff0000",
# "intake": "#ff00ff",
"raw_option": {"default": "red"},
"prefill": "red",
}
# fmt: off
scenarios = [
*all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
*nones(None, "", output=""),
# custom valid
("#000000", "#000000"),
(" #fe1 ", "#fe1"),
("#000000", "#000"),
("#000", "#000"),
("#fe100", "#fe100"),
(" #fe100 ", "#fe100"),
("#ABCDEF", "#ABCDEF"),
("#ABCDEF", "#abcdef"),
('1337', "#1337"), # rgba=(17, 51, 51, 0.47)
("000000", "#000"),
("#feaf", "#fea"), # `#feaf` is `#fea` with alpha at `f|100%` -> equivalent to `#fea`
# named
("red", "#f00"),
("yellow", "#ff0"),
# custom fail
*xpass(scenarios=[
("#feaf", "#feaf"),
], reason="Should fail; not a legal color value"),
("000000", FAIL),
("#12", FAIL),
("#gggggg", FAIL),
("#01010101af", FAIL),
*xfail(scenarios=[
("red", "#ff0000"),
("yellow", "#ffff00"),
], reason="Should work with pydantic"),
# readonly
("#ffff00", "#fe100", {"readonly": True, "current_value": "#fe100"}),
("#ffff00", "#000", {"readonly": True, "default": "#000"}),
]
# fmt: on
@ -796,10 +814,8 @@ class TestNumber(BaseTest):
*nones(None, "", output=None),
*unchanged(0, 1, -1, 1337),
*xpass(scenarios=[(False, False)], reason="should fail or output as `0`"),
*xpass(scenarios=[(True, True)], reason="should fail or output as `1`"),
*all_as("0", 0, output=0),
*all_as("1", 1, output=1),
*all_as(False, "0", 0, output=0), # FIXME should `False` fail instead?
*all_as(True, "1", 1, output=1), # FIXME should `True` fail instead?
*all_as("1337", 1337, output=1337),
*xfail(scenarios=[
("-1", -1)
@ -814,7 +830,7 @@ class TestNumber(BaseTest):
(-10, -10, {"default": 10}),
(-10, -10, {"default": 10, "optional": True}),
# readonly
(1337, 10000, {"readonly": True, "current_value": 10000}),
(1337, 10000, {"readonly": True, "default": "10000"}),
]
# fmt: on
# FIXME should `step` be some kind of "multiple of"?
@ -839,14 +855,20 @@ class TestBoolean(BaseTest):
*all_fails("none", "None"), # FIXME should output as `0` (default) like other none values when required?
*all_as(None, "", output=0, raw_option={"optional": True}), # FIXME should output as `None`?
*all_as("none", "None", output=None, raw_option={"optional": True}),
# FIXME even if default is explicity `None|""`, it ends up with class_default `0`
*all_as(None, "", output=0, raw_option={"default": None}), # FIXME this should fail, default is `None`
*all_as(None, "", output=0, raw_option={"optional": True, "default": None}), # FIXME even if default is explicity None, it ends up with class_default
*all_as(None, "", output=0, raw_option={"default": ""}), # FIXME this should fail, default is `""`
*all_as(None, "", output=0, raw_option={"optional": True, "default": ""}), # FIXME even if default is explicity None, it ends up with class_default
# With "none" behavior is ok
*all_fails(None, "", raw_option={"default": "none"}),
*all_as(None, "", output=None, raw_option={"optional": True, "default": "none"}),
{
"raw_options": [
{"default": None},
{"default": ""},
{"default": "none"},
{"default": "None"}
],
"scenarios": [
# All none values fails if default is overriden
*all_fails(None, "", "none", "None"),
# All none values ends up as None if default is overriden
*all_as(None, "", "none", "None", output=None, raw_option={"optional": True}),
]
},
# Unhandled types should fail
*all_fails(1337, "1337", "string", [], "[]", ",", "one,two"),
*all_fails(1337, "1337", "string", [], "[]", ",", "one,two", {"optional": True}),
@ -879,7 +901,7 @@ class TestBoolean(BaseTest):
"scenarios": all_fails("", "y", "n", error=AssertionError),
},
# readonly
(1, 0, {"readonly": True, "current_value": 0}),
(1, 0, {"readonly": True, "default": 0}),
]
@ -896,8 +918,12 @@ class TestDate(BaseTest):
}
# fmt: off
scenarios = [
*all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
# Those passes since False|True are parsed as 0|1 then int|float are considered a timestamp in seconds which ends up as default Unix date
*all_as(False, True, 0, 1, 1337, 13.37, "0", "1", "1337", "13.37", output="1970-01-01"),
# Those are negative one second timestamp ending up as Unix date - 1 sec (so day change)
*all_as(-1, "-1", output="1969-12-31"),
*all_fails([], ["one"], {}, raw_option={"optional": True}),
*all_fails("none", "_none", "False", "True", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
*nones(None, "", output=""),
# custom valid
("2070-12-31", "2070-12-31"),
@ -906,18 +932,16 @@ class TestDate(BaseTest):
("2025-06-15T13:45:30", "2025-06-15"),
("2025-06-15 13:45:30", "2025-06-15")
], reason="iso date repr should be valid and extra data striped"),
*xfail(scenarios=[
(1749938400, "2025-06-15"),
(1749938400.0, "2025-06-15"),
("1749938400", "2025-06-15"),
("1749938400.0", "2025-06-15"),
], reason="timestamp could be an accepted value"),
(1749938400, "2025-06-14"),
(1749938400.0, "2025-06-14"),
("1749938400", "2025-06-14"),
("1749938400.0", "2025-06-14"),
# custom invalid
("29-12-2070", FAIL),
("12-01-10", FAIL),
("2022-02-29", FAIL),
# readonly
("2070-12-31", "2024-02-29", {"readonly": True, "current_value": "2024-02-29"}),
("2070-12-31", "2024-02-29", {"readonly": True, "default": "2024-02-29"}),
]
# fmt: on
@ -935,22 +959,26 @@ class TestTime(BaseTest):
}
# fmt: off
scenarios = [
*all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
# Those passes since False|True are parsed as 0|1 then int|float are considered a timestamp in seconds but we don't take seconds into account so -> 00:00
*all_as(False, True, 0, 1, 13.37, "0", "1", "13.37", output="00:00"),
# 1337 seconds == 22 minutes
*all_as(1337, "1337", output="00:22"),
# Negative timestamp fails
*all_fails(-1, "-1", error=OverflowError), # FIXME should handle that as a validation error
# *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
*all_fails("none", "_none", "False", "True", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
*nones(None, "", output=""),
# custom valid
*unchanged("00:00", "08:00", "12:19", "20:59", "23:59"),
("3:00", "3:00"), # FIXME should fail or output as `"03:00"`?
*xfail(scenarios=[
("3:00", "03:00"),
("23:1", "23:01"),
("22:35:05", "22:35"),
("22:35:03.514", "22:35"),
], reason="time as iso format could be valid"),
# custom invalid
("24:00", FAIL),
("23:1", FAIL),
("23:005", FAIL),
# readonly
("00:00", "08:00", {"readonly": True, "current_value": "08:00"}),
("00:00", "08:00", {"readonly": True, "default": "08:00"}),
]
# fmt: on
@ -973,72 +1001,75 @@ class TestEmail(BaseTest):
*nones(None, "", output=""),
("\n Abc@example.tld ", "Abc@example.tld"),
*xfail(scenarios=[("admin@ynh.local", "admin@ynh.local")], reason="Should this pass?"),
# readonly
("Abc@example.tld", "admin@ynh.local", {"readonly": True, "current_value": "admin@ynh.local"}),
("Abc@example.tld", "admin@ynh.org", {"readonly": True, "default": "admin@ynh.org"}),
# Next examples are from https://github.com/JoshData/python-email-validator/blob/main/tests/test_syntax.py
# valid email values
("Abc@example.tld", "Abc@example.tld"),
("Abc.123@test-example.com", "Abc.123@test-example.com"),
("user+mailbox/department=shipping@example.tld", "user+mailbox/department=shipping@example.tld"),
("伊昭傑@郵件.商務", "伊昭傑@郵件.商務"),
("राम@मोहन.ईन्फो", "राम@मोहन.ईन्फो"),
("юзер@екзампл.ком", "юзер@екзампл.ком"),
("θσερ@εχαμπλε.ψομ", "θσερ@εχαμπλε.ψομ"),
("葉士豪@臺網中心.tw", "葉士豪@臺網中心.tw"),
("jeff@臺網中心.tw", "jeff@臺網中心.tw"),
("葉士豪@臺網中心.台灣", "葉士豪@臺網中心.台灣"),
("jeff葉@臺網中心.tw", "jeff葉@臺網中心.tw"),
("ñoñó@example.tld", "ñoñó@example.tld"),
("甲斐黒川日本@example.tld", "甲斐黒川日本@example.tld"),
("чебурашкаящик-с-апельсинами.рф@example.tld", "чебурашкаящик-с-апельсинами.рф@example.tld"),
("उदाहरण.परीक्ष@domain.with.idn.tld", "उदाहरण.परीक्ष@domain.with.idn.tld"),
("ιωάννης@εεττ.gr", "ιωάννης@εεττ.gr"),
*unchanged(
"Abc@example.tld",
"Abc.123@test-example.com",
"user+mailbox/department=shipping@example.tld",
"伊昭傑@郵件.商務",
"राम@मोहन.ईन्फो",
"юзер@екзампл.ком",
"θσερ@εχαμπλε.ψομ",
"葉士豪@臺網中心.tw",
"jeff@臺網中心.tw",
"葉士豪@臺網中心.台灣",
"jeff葉@臺網中心.tw",
"ñoñó@example.tld",
"甲斐黒川日本@example.tld",
"чебурашкаящик-с-апельсинами.рф@example.tld",
"उदाहरण.परीक्ष@domain.with.idn.tld",
"ιωάννης@εεττ.gr",
),
# invalid email (Hiding because our current regex is very permissive)
# ("my@localhost", FAIL),
# ("my@.leadingdot.com", FAIL),
# ("my@leadingfwdot.com", FAIL),
# ("my@twodots..com", FAIL),
# ("my@twofwdots.com", FAIL),
# ("my@trailingdot.com.", FAIL),
# ("my@trailingfwdot.com", FAIL),
# ("me@-leadingdash", FAIL),
# ("me@leadingdashfw", FAIL),
# ("me@trailingdash-", FAIL),
# ("me@trailingdashfw", FAIL),
# ("my@baddash.-.com", FAIL),
# ("my@baddash.-a.com", FAIL),
# ("my@baddash.b-.com", FAIL),
# ("my@baddashfw..com", FAIL),
# ("my@baddashfw.a.com", FAIL),
# ("my@baddashfw.b.com", FAIL),
# ("my@example.com\n", FAIL),
# ("my@example\n.com", FAIL),
# ("me@x!", FAIL),
# ("me@x ", FAIL),
# (".leadingdot@domain.com", FAIL),
# ("twodots..here@domain.com", FAIL),
# ("trailingdot.@domain.email", FAIL),
# ("me@⒈wouldbeinvalid.com", FAIL),
("@example.com", FAIL),
# ("\nmy@example.com", FAIL),
("m\ny@example.com", FAIL),
("my\n@example.com", FAIL),
# ("11111111112222222222333333333344444444445555555555666666666677777@example.com", FAIL),
# ("111111111122222222223333333333444444444455555555556666666666777777@example.com", FAIL),
# ("me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444444444455555555556.com", FAIL),
# ("me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com", FAIL),
# ("me@中1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com", FAIL),
# ("my.long.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333333344444.info", FAIL),
# ("my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333.info", FAIL),
# ("my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info", FAIL),
# ("my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444.info", FAIL),
# ("my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info", FAIL),
# ("me@bad-tld-1", FAIL),
# ("me@bad.tld-2", FAIL),
# ("me@xn--0.tld", FAIL),
# ("me@yy--0.tld", FAIL),
# ("me@yy0.tld", FAIL),
*all_fails(
"my@localhost",
"my@.leadingdot.com",
"my@leadingfwdot.com",
"my@twodots..com",
"my@twofwdots.com",
"my@trailingdot.com.",
"my@trailingfwdot.com",
"me@-leadingdash",
"me@leadingdashfw",
"me@trailingdash-",
"me@trailingdashfw",
"my@baddash.-.com",
"my@baddash.-a.com",
"my@baddash.b-.com",
"my@baddashfw..com",
"my@baddashfw.a.com",
"my@baddashfw.b.com",
"my@example\n.com",
"me@x!",
"me@x ",
".leadingdot@domain.com",
"twodots..here@domain.com",
"trailingdot.@domain.email",
"me@⒈wouldbeinvalid.com",
"@example.com",
"m\ny@example.com",
"my\n@example.com",
"11111111112222222222333333333344444444445555555555666666666677777@example.com",
"111111111122222222223333333333444444444455555555556666666666777777@example.com",
"me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444444444455555555556.com",
"me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com",
"me@中1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com",
"my.long.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333333344444.info",
"my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333.info",
"my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info",
"my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444.info",
"my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info",
"me@bad-tld-1",
"me@bad.tld-2",
"me@xn--0.tld",
"me@yy--0.tld",
"me@yy0.tld",
)
]
# fmt: on
@ -1087,7 +1118,7 @@ class TestWebPath(BaseTest):
("https://example.com/folder", "/https://example.com/folder")
], reason="Should fail or scheme+domain removed"),
# readonly
("/overwrite", "/value", {"readonly": True, "current_value": "/value"}),
("/overwrite", "/value", {"readonly": True, "default": "/value"}),
# FIXME should path have forbidden_chars?
]
# fmt: on
@ -1111,21 +1142,17 @@ class TestUrl(BaseTest):
*nones(None, "", output=""),
("http://some.org/folder/file.txt", "http://some.org/folder/file.txt"),
(' https://www.example.com \n', 'https://www.example.com'),
# readonly
("https://overwrite.org", "https://example.org", {"readonly": True, "current_value": "https://example.org"}),
("https://overwrite.org", "https://example.org", {"readonly": True, "default": "https://example.org"}),
# rest is taken from https://github.com/pydantic/pydantic/blob/main/tests/test_networks.py
# valid
*unchanged(
# Those are valid but not sure how they will output with pydantic
'http://example.org',
'http://test',
'http://localhost',
'https://example.org/whatever/next/',
'https://example.org',
'http://localhost',
'http://localhost/',
'http://localhost:8000',
'http://localhost:8000/',
'https://foo_bar.example.com/',
'http://example.co.jp',
'http://www.example.com/a%C2%B1b',
@ -1149,29 +1176,31 @@ class TestUrl(BaseTest):
'http://twitter.com/@handle/',
'http://11.11.11.11.example.com/action',
'http://abc.11.11.11.11.example.com/action',
'http://example#',
'http://example/#',
'http://example/#fragment',
'http://example/?#',
'http://example.org/path#',
'http://example.org/path#fragment',
'http://example.org/path?query#',
'http://example.org/path?query#fragment',
'https://foo_bar.example.com/',
'https://exam_ple.com/',
'HTTP://EXAMPLE.ORG',
'https://example.org',
'https://example.org?a=1&b=2',
'https://example.org#a=3;b=3',
'https://example.xn--p1ai',
'https://example.xn--vermgensberatung-pwb',
'https://example.xn--zfr164b',
),
# Pydantic default parsing add a final `/`
('https://foo_bar.example.com/', 'https://foo_bar.example.com/'),
('https://exam_ple.com/', 'https://exam_ple.com/'),
*xfail(scenarios=[
(' https://www.example.com \n', 'https://www.example.com/'),
('HTTP://EXAMPLE.ORG', 'http://example.org/'),
('https://example.org', 'https://example.org/'),
('https://example.org?a=1&b=2', 'https://example.org/?a=1&b=2'),
('https://example.org#a=3;b=3', 'https://example.org/#a=3;b=3'),
('https://example.xn--p1ai', 'https://example.xn--p1ai/'),
('https://example.xn--vermgensberatung-pwb', 'https://example.xn--vermgensberatung-pwb/'),
('https://example.xn--zfr164b', 'https://example.xn--zfr164b/'),
], reason="pydantic default behavior would append a final `/`"),
('http://test', 'http://test'),
('http://localhost', 'http://localhost'),
('http://localhost/', 'http://localhost/'),
('http://localhost:8000', 'http://localhost:8000'),
('http://localhost:8000/', 'http://localhost:8000/'),
('http://example#', 'http://example#'),
('http://example/#', 'http://example/#'),
('http://example/#fragment', 'http://example/#fragment'),
('http://example/?#', 'http://example/?#'),
], reason="Should this be valid?"),
# invalid
*all_fails(
'ftp://example.com/',
@ -1182,15 +1211,13 @@ class TestUrl(BaseTest):
"/",
"+http://example.com/",
"ht*tp://example.com/",
"http:///",
"http://??",
"https://example.org more",
"http://2001:db8::ff00:42:8329",
"http://[192.168.1.1]:8329",
"http://example.com:99999",
),
*xpass(scenarios=[
("http:///", "http:///"),
("http://??", "http://??"),
("https://example.org more", "https://example.org more"),
("http://2001:db8::ff00:42:8329", "http://2001:db8::ff00:42:8329"),
("http://[192.168.1.1]:8329", "http://[192.168.1.1]:8329"),
("http://example.com:99999", "http://example.com:99999"),
], reason="Should fail"),
]
# fmt: on
@ -1361,7 +1388,6 @@ class TestSelect(BaseTest):
# [-1, 0, 1]
"raw_options": [
{"choices": [-1, 0, 1, 10]},
{"choices": {-1: "verbose -one", 0: "verbose zero", 1: "verbose one", 10: "verbose ten"}},
],
"scenarios": [
*nones(None, "", output=""),
@ -1375,6 +1401,18 @@ class TestSelect(BaseTest):
*all_fails("100", 100),
]
},
{
"raw_options": [
{"choices": {-1: "verbose -one", 0: "verbose zero", 1: "verbose one", 10: "verbose ten"}},
{"choices": {"-1": "verbose -one", "0": "verbose zero", "1": "verbose one", "10": "verbose ten"}},
],
"scenarios": [
*nones(None, "", output=""),
*all_fails(-1, 0, 1, 10), # Should pass? converted to str?
*unchanged("-1", "0", "1", "10"),
*all_fails("100", 100),
]
},
# [True, False, None]
*unchanged(True, False, raw_option={"choices": [True, False, None]}), # FIXME we should probably forbid None in choices
(None, FAIL, {"choices": [True, False, None]}),
@ -1402,7 +1440,7 @@ class TestSelect(BaseTest):
]
},
# readonly
("one", "two", {"readonly": True, "choices": ["one", "two"], "current_value": "two"}),
("one", "two", {"readonly": True, "choices": ["one", "two"], "default": "two"}),
]
# fmt: on
@ -1411,7 +1449,7 @@ class TestSelect(BaseTest):
# │ TAGS │
# ╰───────────────────────────────────────────────────────╯
# [], ["one"], {}
class TestTags(BaseTest):
raw_option = {"type": "tags", "id": "tags_id"}
prefill = {
@ -1420,12 +1458,7 @@ class TestTags(BaseTest):
}
# fmt: off
scenarios = [
*nones(None, [], "", output=""),
# FIXME `","` could be considered a none value which kinda already is since it fail when required
(",", FAIL),
*xpass(scenarios=[
(",", ",", {"optional": True})
], reason="Should output as `''`? ie: None"),
*nones(None, [], "", ",", output=""),
{
"raw_options": [
{},
@ -1445,12 +1478,12 @@ class TestTags(BaseTest):
# basic types (not in a list) should fail
*all_fails(True, False, -1, 0, 1, 1337, 13.37, {}),
# Mixed choices should fail
([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], FAIL, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
("False,True,-1,0,1,1337,13.37,[],['one'],{}", FAIL, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
*all_fails(*([t] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
*all_fails(*([str(t)] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], YunohostError, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
("False,True,-1,0,1,1337,13.37,[],['one'],{}", YunohostError, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
*all_fails(*([t] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}, error=YunohostError),
*all_fails(*([str(t)] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}, error=YunohostError),
# readonly
("one", "one,two", {"readonly": True, "choices": ["one", "two"], "current_value": "one,two"}),
("one", "one,two", {"readonly": True, "choices": ["one", "two"], "default": "one,two"}),
]
# fmt: on
@ -1566,8 +1599,7 @@ class TestApp(BaseTest):
],
"scenarios": [
# FIXME there are currently 3 different nones (`None`, `""` and `_none`), choose one?
*nones(None, output=None), # FIXME Should return chosen none?
*nones("", output=""), # FIXME Should return chosen none?
*nones(None, "", output=""), # FIXME Should return chosen none?
*xpass(scenarios=[
("_none", "_none"),
("_none", "_none", {"default": "_none"}),
@ -1590,7 +1622,7 @@ class TestApp(BaseTest):
(installed_webapp["id"], installed_webapp["id"], {"filter": "is_webapp"}),
(installed_webapp["id"], FAIL, {"filter": "is_webapp == false"}),
(installed_webapp["id"], FAIL, {"filter": "id != 'my_webapp'"}),
(None, None, {"filter": "id == 'fake_app'", "optional": True}),
(None, "", {"filter": "id == 'fake_app'", "optional": True}),
]
},
{
@ -1796,9 +1828,7 @@ class TestGroup(BaseTest):
"scenarios": [
("custom_group", "custom_group"),
*all_as("", None, output="visitors", raw_option={"default": "visitors"}),
*xpass(scenarios=[
("", "custom_group", {"default": "custom_group"}),
], reason="Should throw 'default must be in (None, 'all_users', 'visitors', 'admins')"),
("", YunohostError, {"default": "custom_group"}), # Not allowed to set a default which is not a default group
# readonly
("admins", YunohostError, {"readonly": True}), # readonly is forbidden
]
@ -1817,13 +1847,6 @@ class TestGroup(BaseTest):
"prefill": "admins",
}
)
# FIXME This should fail, not allowed to set a default which is not a default group
super().test_options_prompted_with_ask_help(
prefill_data={
"raw_option": {"default": "custom_group"},
"prefill": "custom_group",
}
)
def test_scenarios(self, intake, expected_output, raw_option, data):
with patch_groups(**data):
@ -1880,12 +1903,12 @@ def test_options_query_string():
"string_id": "string",
"text_id": "text\ntext",
"password_id": "sUpRSCRT",
"color_id": "#ffff00",
"color_id": "#ff0",
"number_id": 10,
"boolean_id": 1,
"date_id": "2030-03-06",
"time_id": "20:55",
"email_id": "coucou@ynh.local",
"email_id": "coucou@ynh.org",
"path_id": "/ynh-dev",
"url_id": "https://yunohost.org",
"file_id": file_content1,
@ -1908,7 +1931,7 @@ def test_options_query_string():
"&boolean_id=y"
"&date_id=2030-03-06"
"&time_id=20:55"
"&email_id=coucou@ynh.local"
"&email_id=coucou@ynh.org"
"&path_id=ynh-dev/"
"&url_id=https://yunohost.org"
f"&file_id={file_repr}"
@ -1925,9 +1948,7 @@ def test_options_query_string():
"&fake_id=fake_value"
)
def _assert_correct_values(options, raw_options):
form = {option.id: option.value for option in options}
def _assert_correct_values(options, form, raw_options):
for k, v in results.items():
if k == "file_id":
assert os.path.exists(form["file_id"]) and os.path.isfile(
@ -1943,24 +1964,24 @@ def test_options_query_string():
with patch_interface("api"), patch_file_api(file_content1) as b64content:
with patch_query_string(b64content.decode("utf-8")) as query_string:
options = ask_questions_and_parse_answers(raw_options, query_string)
_assert_correct_values(options, raw_options)
options, form = ask_questions_and_parse_answers(raw_options, query_string)
_assert_correct_values(options, form, raw_options)
with patch_interface("cli"), patch_file_cli(file_content1) as filepath:
with patch_query_string(filepath) as query_string:
options = ask_questions_and_parse_answers(raw_options, query_string)
_assert_correct_values(options, raw_options)
options, form = ask_questions_and_parse_answers(raw_options, query_string)
_assert_correct_values(options, form, raw_options)
def test_question_string_default_type():
questions = {"some_string": {}}
answers = {"some_string": "some_value"}
out = ask_questions_and_parse_answers(questions, answers)[0]
assert out.id == "some_string"
assert out.type == "string"
assert out.value == "some_value"
options, form = ask_questions_and_parse_answers(questions, answers)
option = options[0]
assert option.id == "some_string"
assert option.type == "string"
assert form[option.id] == "some_value"
def test_option_default_type_with_choices_is_select():
@ -1972,10 +1993,10 @@ def test_option_default_type_with_choices_is_select():
}
answers = {"some_choices": "a", "some_legacy": "a"}
options = ask_questions_and_parse_answers(questions, answers)
options, form = ask_questions_and_parse_answers(questions, answers)
for option in options:
assert option.type == "select"
assert option.value == "a"
assert form[option.id] == "a"
@pytest.mark.skip # we should do something with this example

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff