mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Merge pull request #1677 from YunoHost/pydantic
ConfigPanel: switch to pydantic 3/3
This commit is contained in:
commit
093c707eb6
12 changed files with 1946 additions and 1337 deletions
2
debian/control
vendored
2
debian/control
vendored
|
@ -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
|
||||||
|
|
4
doc/generate_json_schema.py
Normal file
4
doc/generate_json_schema.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from yunohost.utils.configpanel import ConfigPanelModel
|
||||||
|
|
||||||
|
|
||||||
|
print(ConfigPanelModel.schema_json(indent=2))
|
89
src/app.py
89
src/app.py
|
@ -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:
|
||||||
|
|
75
src/dns.py
75
src/dns.py
|
@ -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(
|
"name": m18n.n(
|
||||||
"registrar_infos"
|
"registrar_infos"
|
||||||
), # This is meant to name the config panel section, for proper display in the webadmin
|
), # 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",
|
|
||||||
"style": "info",
|
|
||||||
"ask": m18n.n(
|
|
||||||
"domain_dns_registrar_managed_in_parent_domain",
|
"domain_dns_registrar_managed_in_parent_domain",
|
||||||
parent_domain=parent_domain,
|
parent_domain=parent_domain,
|
||||||
parent_domain_link=parent_domain_link,
|
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):
|
||||||
|
|
134
src/domain.py
134
src/domain.py
|
@ -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"):
|
|
||||||
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_cert_renew_help
|
||||||
# i18n: domain_config_default_app_help
|
# i18n: domain_config_default_app_help
|
||||||
# i18n: domain_config_xmpp_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):
|
any_filter = all(self.filter_key)
|
||||||
toml = super()._get_raw_config()
|
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
|
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):
|
||||||
|
|
|
@ -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
|
||||||
|
|
121
src/settings.py
121
src/settings.py
|
@ -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"))
|
||||||
|
|
||||||
|
if operation_logger:
|
||||||
operation_logger.success()
|
operation_logger.success()
|
||||||
|
|
||||||
def _get_raw_config(self):
|
def _get_raw_config(self) -> "RawConfig":
|
||||||
toml = super()._get_raw_config()
|
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}")
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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@yy--0.tld",
|
||||||
# ("me@yy--0.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
1303
src/utils/form.py
1303
src/utils/form.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue