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-toml, python3-packaging, python3-publicsuffix2
, python3-ldap, python3-zeroconf (>= 0.47), python3-lexicon, , python3-ldap, python3-zeroconf (>= 0.47), python3-lexicon,
, python3-cryptography, python3-jwt , python3-cryptography, python3-jwt
, python-is-python3 , python-is-python3, python3-pydantic, python3-email-validator
, nginx, nginx-extras (>=1.22) , nginx, nginx-extras (>=1.22)
, apt, apt-transport-https, apt-utils, dirmngr , apt, apt-transport-https, apt-utils, dirmngr
, openssh-server, iptables, fail2ban, bind9-dnsutils , 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 subprocess
import tempfile import tempfile
import copy 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 packaging import version
from logging import getLogger from logging import getLogger
from pathlib import Path from pathlib import Path
@ -45,11 +45,12 @@ from moulinette.utils.filesystem import (
chmod, chmod,
) )
from yunohost.utils.configpanel import ConfigPanel, ask_questions_and_parse_answers from yunohost.utils.configpanel import ConfigPanel
from yunohost.utils.form import ( from yunohost.utils.form import (
DomainOption, DomainOption,
WebPathOption, WebPathOption,
hydrate_questions_with_choices, ask_questions_and_parse_answers,
parse_raw_options,
) )
from yunohost.utils.i18n import _value_for_locale from yunohost.utils.i18n import _value_for_locale
from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.error import YunohostError, YunohostValidationError
@ -71,6 +72,12 @@ from yunohost.app_catalog import ( # noqa
APPS_CATALOG_LOGOS, 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") logger = getLogger("yunohost.app")
APPS_SETTING_PATH = "/etc/yunohost/apps/" APPS_SETTING_PATH = "/etc/yunohost/apps/"
@ -121,8 +128,8 @@ def app_info(app, full=False, upgradable=False):
""" """
Get info for a specific app Get info for a specific app
""" """
from yunohost.permission import user_permission_list
from yunohost.domain import domain_config_get from yunohost.domain import domain_config_get
from yunohost.permission import user_permission_list
_assert_is_installed(app) _assert_is_installed(app)
@ -955,8 +962,7 @@ def app_upgrade(
def app_manifest(app, with_screenshot=False): def app_manifest(app, with_screenshot=False):
manifest, extracted_app_folder = _extract_app(app) manifest, extracted_app_folder = _extract_app(app)
raw_questions = manifest.get("install", {}).values() manifest["install"] = parse_raw_options(manifest.get("install", {}), serialize=True)
manifest["install"] = hydrate_questions_with_choices(raw_questions)
# Add a base64 image to be displayed in web-admin # Add a base64 image to be displayed in web-admin
if with_screenshot and Moulinette.interface.type == "api": 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) app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name)
# Retrieve arguments list for install script # Retrieve arguments list for install script
raw_questions = manifest["install"] raw_options = manifest["install"]
questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args) options, form = ask_questions_and_parse_answers(raw_options, prefilled_answers=args)
args = { args = form.dict(exclude_none=True)
question.id: question.value
for question in questions
if not question.readonly and question.value is not None
}
# Validate domain / path availability for webapps # Validate domain / path availability for webapps
# (ideally this should be handled by the resource system for manifest v >= 2 # (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", "?"), "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: if packaging_format >= 2:
for question in questions: for option in options:
# Except user-provider passwords # Except user-provider passwords
# ... which we need to reinject later in the env_dict # ... which we need to reinject later in the env_dict
if question.type == "password": if option.type == "password":
continue continue
app_settings[question.id] = question.value app_settings[option.id] = form[option.id]
_set_app_settings(app_instance_name, app_settings) _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" 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: if packaging_format >= 2:
for question in questions: for option in options:
# Reinject user-provider passwords which are not in the app settings # Reinject user-provider passwords which are not in the app settings
# (cf a few line before) # (cf a few line before)
if question.type == "password": if option.type == "password":
env_dict[question.id] = question.value env_dict[option.id] = form[option.id]
# We want to hav the env_dict in the log ... but not password values # We want to hav the env_dict in the log ... but not password values
env_dict_for_logging = env_dict.copy() env_dict_for_logging = env_dict.copy()
for question in questions: for option in options:
# Or should it be more generally question.redact ? # Or should it be more generally option.redact ?
if question.type == "password": if option.type == "password":
if f"YNH_APP_ARG_{question.id.upper()}" in env_dict_for_logging: if f"YNH_APP_ARG_{option.id.upper()}" in env_dict_for_logging:
del env_dict_for_logging[f"YNH_APP_ARG_{question.id.upper()}"] del env_dict_for_logging[f"YNH_APP_ARG_{option.id.upper()}"]
if question.id in env_dict_for_logging: if option.id in env_dict_for_logging:
del env_dict_for_logging[question.id] del env_dict_for_logging[option.id]
operation_logger.extra.update({"env": env_dict_for_logging}) operation_logger.extra.update({"env": env_dict_for_logging})
@ -1801,30 +1803,39 @@ class AppConfigPanel(ConfigPanel):
entity_type = "app" entity_type = "app"
save_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/settings.yml") 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") config_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/config_panel.toml")
settings_must_be_defined: bool = True
def _run_action(self, action): def _get_raw_settings(self, config: "ConfigPanelModel") -> "RawSettings":
env = {key: str(value) for key, value in self.new_values.items()} return self._call_config_script("show")
self._call_config_script(action, env=env)
def _get_raw_settings(self): def _apply(
self.values = self._call_config_script("show") self,
form: "FormModel",
def _apply(self): previous_settings: dict[str, Any],
env = {key: str(value) for key, value in self.new_values.items()} 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) return_content = self._call_config_script("apply", env=env)
# If the script returned validation error # If the script returned validation error
# raise a ValidationError exception using # raise a ValidationError exception using
# the first key # the first key
if return_content: errors = return_content.get("validation_errors")
for key, message in return_content.get("validation_errors").items(): if errors:
for key, message in errors.items():
raise YunohostValidationError( raise YunohostValidationError(
"app_argument_invalid", "app_argument_invalid",
name=key, name=key,
error=message, 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 from yunohost.hook import hook_exec
if env is None: 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): def _get_registrar_config_section(domain):
from lexicon.providers.auto import _relevant_provider_for_domain from lexicon.providers.auto import _relevant_provider_for_domain
registrar_infos = { registrar_infos = OrderedDict(
"name": m18n.n( {
"registrar_infos" "name": m18n.n(
), # This is meant to name the config panel section, for proper display in the webadmin "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) dns_zone = _get_dns_zone_for_domain(domain)
@ -519,61 +534,35 @@ def _get_registrar_config_section(domain):
else: else:
parent_domain_link = parent_domain parent_domain_link = parent_domain
registrar_infos["registrar"] = OrderedDict( registrar_infos["registrar"]["default"] = "parent_domain"
{ registrar_infos["infos"]["ask"] = m18n.n(
"type": "alert", "domain_dns_registrar_managed_in_parent_domain",
"style": "info", parent_domain=parent_domain,
"ask": m18n.n( parent_domain_link=parent_domain_link,
"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 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... # 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): if is_yunohost_dyndns_domain(dns_zone):
registrar_infos["registrar"] = OrderedDict( registrar_infos["registrar"]["default"] = "yunohost"
{ registrar_infos["infos"]["style"] = "success"
"type": "alert", registrar_infos["infos"]["ask"] = m18n.n("domain_dns_registrar_yunohost")
"style": "success",
"ask": m18n.n("domain_dns_registrar_yunohost"), return registrar_infos
"value": "yunohost",
}
)
return OrderedDict(registrar_infos)
elif is_special_use_tld(dns_zone): elif is_special_use_tld(dns_zone):
registrar_infos["registrar"] = OrderedDict( registrar_infos["infos"]["ask"] = m18n.n("domain_dns_conf_special_use_tld")
{
"type": "alert",
"style": "info",
"ask": m18n.n("domain_dns_conf_special_use_tld"),
"value": None,
}
)
try: try:
registrar = _relevant_provider_for_domain(dns_zone)[0] registrar = _relevant_provider_for_domain(dns_zone)[0]
except ValueError: except ValueError:
registrar_infos["registrar"] = OrderedDict( registrar_infos["registrar"]["default"] = None
{ registrar_infos["infos"]["ask"] = m18n.n("domain_dns_registrar_not_supported")
"type": "alert", registrar_infos["infos"]["style"] = "warning"
"style": "warning",
"ask": m18n.n("domain_dns_registrar_not_supported"),
"value": None,
}
)
else: else:
registrar_infos["registrar"] = OrderedDict( registrar_infos["registrar"]["default"] = registrar
{ registrar_infos["infos"]["ask"] = m18n.n(
"type": "alert", "domain_dns_registrar_supported", registrar=registrar
"style": "info",
"ask": m18n.n("domain_dns_registrar_supported", registrar=registrar),
"value": registrar,
}
) )
TESTED_REGISTRARS = ["ovh", "gandi"] TESTED_REGISTRARS = ["ovh", "gandi"]
@ -601,7 +590,7 @@ def _get_registrar_config_section(domain):
infos["optional"] = infos.get("optional", "False") infos["optional"] = infos.get("optional", "False")
registrar_infos.update(registrar_credentials) registrar_infos.update(registrar_credentials)
return OrderedDict(registrar_infos) return registrar_infos
def _get_registar_settings(domain): def _get_registar_settings(domain):

View file

@ -19,7 +19,7 @@
import os import os
import time import time
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import TYPE_CHECKING, Any, List, Optional, Union
from collections import OrderedDict from collections import OrderedDict
from logging import getLogger 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.utils.dns import is_yunohost_dyndns_domain
from yunohost.log import is_unit_operation 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") logger = getLogger("yunohost.domain")
DOMAIN_SETTINGS_DIR = "/etc/yunohost/domains" DOMAIN_SETTINGS_DIR = "/etc/yunohost/domains"
@ -649,98 +655,83 @@ class DomainConfigPanel(ConfigPanel):
save_path_tpl = f"{DOMAIN_SETTINGS_DIR}/{{entity}}.yml" save_path_tpl = f"{DOMAIN_SETTINGS_DIR}/{{entity}}.yml"
save_mode = "diff" save_mode = "diff"
def get(self, key="", mode="classic"): # i18n: domain_config_cert_renew_help
result = super().get(key=key, mode=mode) # i18n: domain_config_default_app_help
# i18n: domain_config_xmpp_help
if mode == "full": def _get_raw_config(self) -> "RawConfig":
for panel, section, option in self._iterate(): # TODO add mechanism to share some settings with other domains on the same zone
# This injects: raw_config = super()._get_raw_config()
# 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 any_filter = all(self.filter_key)
panel_id, section_id, option_id = self.filter_key
def _get_raw_config(self): raw_config["feature"]["xmpp"]["xmpp"]["default"] = (
toml = super()._get_raw_config()
toml["feature"]["xmpp"]["xmpp"]["default"] = (
1 if self.entity == _get_maindomain() else 0 1 if self.entity == _get_maindomain() else 0
) )
# Portal settings are only available on "topest" domains # Portal settings are only available on "topest" domains
if _get_parent_domain_of(self.entity, topest=True) is not None: 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, # Optimize wether or not to load the DNS section,
# e.g. we don't want to trigger the whole _get_registary_config_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 # when just getting the current value from the feature section
filter_key = self.filter_key.split(".") if self.filter_key != "" else [] if not any_filter or panel_id == "dns":
if not filter_key or filter_key[0] == "dns":
from yunohost.dns import _get_registrar_config_section from yunohost.dns import _get_registrar_config_section
toml["dns"]["registrar"] = _get_registrar_config_section(self.entity) raw_config["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"]
# Cert stuff # 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 from yunohost.certificate import certificate_status
status = certificate_status([self.entity], full=True)["certificates"][ status = certificate_status([self.entity], full=True)["certificates"][
self.entity 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_expired
# i18n: domain_config_cert_summary_selfsigned # i18n: domain_config_cert_summary_selfsigned
# i18n: domain_config_cert_summary_abouttoexpire # i18n: domain_config_cert_summary_abouttoexpire
# i18n: domain_config_cert_summary_ok # i18n: domain_config_cert_summary_ok
# i18n: domain_config_cert_summary_letsencrypt # 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']}" f"domain_config_cert_summary_{status['summary']}"
) )
# FIXME: Ugly hack to save the cert status and reinject it in _get_raw_settings ... for option_id, status_key in [
self.cert_status = status ("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): return raw_config
# TODO add mechanism to share some settings with other domains on the same zone
super()._get_raw_settings()
# FIXME: Ugly hack to save the registar id/value and reinject it in _get_raw_settings ... def _apply(
filter_key = self.filter_key.split(".") if self.filter_key != "" else [] self,
if not filter_key or filter_key[0] == "dns": form: "FormModel",
self.values["registrar"] = self.registar_id 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 "default_app" in next_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"]
):
from yunohost.app import app_ssowatconf, app_map from yunohost.app import app_ssowatconf, app_map
if "/" in app_map(raw=True).get(self.entity, {}): if "/" in app_map(raw=True).get(self.entity, {}):
raise YunohostValidationError( raise YunohostValidationError(
"app_make_default_location_already_used", "app_make_default_location_already_used",
app=self.future_values["default_app"], app=next_settings["default_app"],
domain=self.entity, domain=self.entity,
other_app=app_map(raw=True)[self.entity]["/"]["id"], other_app=app_map(raw=True)[self.entity]["/"]["id"],
) )
@ -753,8 +744,7 @@ class DomainConfigPanel(ConfigPanel):
"portal_theme", "portal_theme",
] ]
if _get_parent_domain_of(self.entity, topest=True) is None and any( if _get_parent_domain_of(self.entity, topest=True) is None and any(
option in self.future_values option in next_settings
and self.new_values[option] != self.values.get(option)
for option in portal_options for option in portal_options
): ):
from yunohost.portal import PORTAL_SETTINGS_DIR 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 # Portal options are also saved in a `domain.portal.yml` file
# that can be read by the portal API. # that can be read by the portal API.
# FIXME remove those from the config panel saved values? # 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_path = Path(f"{PORTAL_SETTINGS_DIR}/{self.entity}.json")
portal_settings = {"apps": {}} portal_settings: dict[str, Any] = {"apps": {}}
if portal_settings_path.exists(): if portal_settings_path.exists():
portal_settings.update(read_json(str(portal_settings_path))) 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 str(portal_settings_path), portal_settings, sort_keys=True, indent=4
) )
super()._apply() super()._apply(form, previous_settings)
# Reload ssowat if default app changed # Reload ssowat if default app changed
if ( if "default_app" in next_settings:
"default_app" in self.future_values
and self.future_values["default_app"] != self.values["default_app"]
):
app_ssowatconf() app_ssowatconf()
stuff_to_regen_conf = [] stuff_to_regen_conf = set()
if ( if "xmpp" in next_settings:
"xmpp" in self.future_values stuff_to_regen_conf.update({"nginx", "metronome"})
and self.future_values["xmpp"] != self.values["xmpp"]
):
stuff_to_regen_conf.append("nginx")
stuff_to_regen_conf.append("metronome")
if ( if "mail_in" in next_settings or "mail_out" in next_settings:
"mail_in" in self.future_values stuff_to_regen_conf.update({"nginx", "postfix", "dovecot", "rspamd"})
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 stuff_to_regen_conf: 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): 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. 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): def __init__(self, operation, related_to=None, **kwargs):
# TODO add a way to not save password on app installation # TODO add a way to not save password on app installation

View file

@ -19,16 +19,30 @@
import os import os
import subprocess import subprocess
from logging import getLogger from logging import getLogger
from typing import TYPE_CHECKING, Any, Union
from moulinette import m18n from moulinette import m18n
from yunohost.utils.error import YunohostError, YunohostValidationError 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.utils.form import BaseOption
from yunohost.regenconf import regen_conf from yunohost.regenconf import regen_conf
from yunohost.firewall import firewall_reload from yunohost.firewall import firewall_reload
from yunohost.log import is_unit_operation from yunohost.log import is_unit_operation
from yunohost.utils.legacy import translate_legacy_settings_to_configpanel_settings 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") logger = getLogger("yunohost.settings")
SETTINGS_PATH = "/etc/yunohost/settings.yml" SETTINGS_PATH = "/etc/yunohost/settings.yml"
@ -120,47 +134,49 @@ class SettingsConfigPanel(ConfigPanel):
entity_type = "global" entity_type = "global"
save_path_tpl = SETTINGS_PATH save_path_tpl = SETTINGS_PATH
save_mode = "diff" 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") 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) 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 # Dirty hack to let settings_get() to work from a python script
if isinstance(result, str) and result in ["True", "False"]: if isinstance(result, str) and result in ["True", "False"]:
result = bool(result == "True") result = bool(result == "True")
return result return result
def reset(self, key="", operation_logger=None): def reset(
self.filter_key = key self,
key: Union[str, None] = None,
operation_logger: Union["OperationLogger", None] = None,
) -> None:
self.filter_key = parse_filter_key(key)
# Read config panel toml # Read config panel toml
self._get_config_panel() self.config, self.form = self._get_config_panel(prevalidate=True)
if not self.config: # FIXME find a better way to exclude previous settings
raise YunohostValidationError("config_no_panel") previous_settings = self.form.dict()
# Replace all values with default values for option in self.config.options:
self.values = self._get_default_values() 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: if operation_logger:
operation_logger.start() operation_logger.start()
try: try:
self._apply() self._apply(self.form, previous_settings)
except YunohostError: except YunohostError:
raise raise
# Script got manually interrupted ... # Script got manually interrupted ...
@ -178,10 +194,12 @@ class SettingsConfigPanel(ConfigPanel):
raise raise
logger.success(m18n.n("global_settings_reset_success")) logger.success(m18n.n("global_settings_reset_success"))
operation_logger.success()
def _get_raw_config(self): if operation_logger:
toml = super()._get_raw_config() operation_logger.success()
def _get_raw_config(self) -> "RawConfig":
raw_config = super()._get_raw_config()
# Dynamic choice list for portal themes # Dynamic choice list for portal themes
THEMEDIR = "/usr/share/ssowat/portal/assets/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)] themes = [d for d in os.listdir(THEMEDIR) if os.path.isdir(THEMEDIR + d)]
except Exception: except Exception:
themes = ["unsplash", "vapor", "light", "default", "clouds"] 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): def _get_raw_settings(self, config: "ConfigPanelModel") -> "RawSettings":
super()._get_raw_settings() raw_settings = super()._get_raw_settings(config)
# Specific logic for those settings who are "virtual" settings # Specific logic for those settings who are "virtual" settings
# and only meant to have a custom setter mapped to tools_rootpw # and only meant to have a custom setter mapped to tools_rootpw
self.values["root_password"] = "" raw_settings["root_password"] = ""
self.values["root_password_confirm"] = "" raw_settings["root_password_confirm"] = ""
# Specific logic for virtual setting "passwordless_sudo" # Specific logic for virtual setting "passwordless_sudo"
try: try:
from yunohost.utils.ldap import _get_ldap_interface from yunohost.utils.ldap import _get_ldap_interface
ldap = _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"] "ou=sudo", "cn=admins", ["sudoOption"]
)[0].get("sudoOption", []) )[0].get("sudoOption", [])
except Exception: except Exception:
self.values["passwordless_sudo"] = False raw_settings["passwordless_sudo"] = False
def _apply(self): return raw_settings
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)
self.values = { def _apply(
k: v for k, v in self.values.items() if k not in self.virtual_settings self,
} form: "FormModel",
self.new_values = { previous_settings: dict[str, Any],
k: v for k, v in self.new_values.items() if k not in self.virtual_settings exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None,
} ) -> None:
root_password = form.get("root_password", None)
assert all(v not in self.future_values for v in self.virtual_settings) 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 and root_password.strip():
if root_password != root_password_confirm: if root_password != root_password_confirm:
@ -243,15 +259,20 @@ class SettingsConfigPanel(ConfigPanel):
{"sudoOption": ["!authenticate"] if passwordless_sudo else []}, {"sudoOption": ["!authenticate"] if passwordless_sudo else []},
) )
super()._apply() # First save settings except virtual + default ones
super()._apply(form, previous_settings, exclude=self.virtual_settings)
settings = { next_settings = {
k: v for k, v in self.future_values.items() if self.values.get(k) != v 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: try:
# FIXME not sure to understand why we need the previous value if
# updated_settings has already been filtered
trigger_post_change_hook( trigger_post_change_hook(
setting_name, self.values.get(setting_name), value setting_name, previous_settings.get(setting_name), value
) )
except Exception as e: except Exception as e:
logger.error(f"Post-change hook for setting failed : {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): with pytest.raises(YunohostValidationError):
app_config_get(config_app, "main.components.nonexistent") 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): 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): 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(): 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(): def test_magic_guess_registrar_ovh():
assert ( assert (
_get_registrar_config_section("yolo.yunohost.org")["registrar"]["value"] _get_registrar_config_section("yolo.yunohost.org")["registrar"]["default"]
== "ovh" == "ovh"
) )
def test_magic_guess_registrar_yunodyndns(): def test_magic_guess_registrar_yunodyndns():
assert ( assert (
_get_registrar_config_section("yolo.nohost.me")["registrar"]["value"] _get_registrar_config_section("yolo.nohost.me")["registrar"]["default"]
== "yunohost" == "yunohost"
) )

View file

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