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-ldap, python3-zeroconf (>= 0.47), python3-lexicon,
|
||||
, python3-cryptography, python3-jwt
|
||||
, python-is-python3
|
||||
, python-is-python3, python3-pydantic, python3-email-validator
|
||||
, nginx, nginx-extras (>=1.22)
|
||||
, apt, apt-transport-https, apt-utils, dirmngr
|
||||
, openssh-server, iptables, fail2ban, bind9-dnsutils
|
||||
|
|
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 tempfile
|
||||
import copy
|
||||
from typing import List, Tuple, Dict, Any, Iterator, Optional
|
||||
from typing import TYPE_CHECKING, List, Tuple, Dict, Any, Iterator, Optional, Union
|
||||
from packaging import version
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
|
@ -45,11 +45,12 @@ from moulinette.utils.filesystem import (
|
|||
chmod,
|
||||
)
|
||||
|
||||
from yunohost.utils.configpanel import ConfigPanel, ask_questions_and_parse_answers
|
||||
from yunohost.utils.configpanel import ConfigPanel
|
||||
from yunohost.utils.form import (
|
||||
DomainOption,
|
||||
WebPathOption,
|
||||
hydrate_questions_with_choices,
|
||||
ask_questions_and_parse_answers,
|
||||
parse_raw_options,
|
||||
)
|
||||
from yunohost.utils.i18n import _value_for_locale
|
||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||
|
@ -71,6 +72,12 @@ from yunohost.app_catalog import ( # noqa
|
|||
APPS_CATALOG_LOGOS,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.typing import AbstractSetIntStr, MappingIntStrAny
|
||||
|
||||
from yunohost.utils.configpanel import ConfigPanelModel, RawSettings
|
||||
from yunohost.utils.form import FormModel
|
||||
|
||||
logger = getLogger("yunohost.app")
|
||||
|
||||
APPS_SETTING_PATH = "/etc/yunohost/apps/"
|
||||
|
@ -121,8 +128,8 @@ def app_info(app, full=False, upgradable=False):
|
|||
"""
|
||||
Get info for a specific app
|
||||
"""
|
||||
from yunohost.permission import user_permission_list
|
||||
from yunohost.domain import domain_config_get
|
||||
from yunohost.permission import user_permission_list
|
||||
|
||||
_assert_is_installed(app)
|
||||
|
||||
|
@ -955,8 +962,7 @@ def app_upgrade(
|
|||
def app_manifest(app, with_screenshot=False):
|
||||
manifest, extracted_app_folder = _extract_app(app)
|
||||
|
||||
raw_questions = manifest.get("install", {}).values()
|
||||
manifest["install"] = hydrate_questions_with_choices(raw_questions)
|
||||
manifest["install"] = parse_raw_options(manifest.get("install", {}), serialize=True)
|
||||
|
||||
# Add a base64 image to be displayed in web-admin
|
||||
if with_screenshot and Moulinette.interface.type == "api":
|
||||
|
@ -1098,13 +1104,9 @@ def app_install(
|
|||
app_setting_path = os.path.join(APPS_SETTING_PATH, app_instance_name)
|
||||
|
||||
# Retrieve arguments list for install script
|
||||
raw_questions = manifest["install"]
|
||||
questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args)
|
||||
args = {
|
||||
question.id: question.value
|
||||
for question in questions
|
||||
if not question.readonly and question.value is not None
|
||||
}
|
||||
raw_options = manifest["install"]
|
||||
options, form = ask_questions_and_parse_answers(raw_options, prefilled_answers=args)
|
||||
args = form.dict(exclude_none=True)
|
||||
|
||||
# Validate domain / path availability for webapps
|
||||
# (ideally this should be handled by the resource system for manifest v >= 2
|
||||
|
@ -1141,15 +1143,15 @@ def app_install(
|
|||
"current_revision": manifest.get("remote", {}).get("revision", "?"),
|
||||
}
|
||||
|
||||
# If packaging_format v2+, save all install questions as settings
|
||||
# If packaging_format v2+, save all install options as settings
|
||||
if packaging_format >= 2:
|
||||
for question in questions:
|
||||
for option in options:
|
||||
# Except user-provider passwords
|
||||
# ... which we need to reinject later in the env_dict
|
||||
if question.type == "password":
|
||||
if option.type == "password":
|
||||
continue
|
||||
|
||||
app_settings[question.id] = question.value
|
||||
app_settings[option.id] = form[option.id]
|
||||
|
||||
_set_app_settings(app_instance_name, app_settings)
|
||||
|
||||
|
@ -1202,23 +1204,23 @@ def app_install(
|
|||
app_instance_name, args=args, workdir=extracted_app_folder, action="install"
|
||||
)
|
||||
|
||||
# If packaging_format v2+, save all install questions as settings
|
||||
# If packaging_format v2+, save all install options as settings
|
||||
if packaging_format >= 2:
|
||||
for question in questions:
|
||||
for option in options:
|
||||
# Reinject user-provider passwords which are not in the app settings
|
||||
# (cf a few line before)
|
||||
if question.type == "password":
|
||||
env_dict[question.id] = question.value
|
||||
if option.type == "password":
|
||||
env_dict[option.id] = form[option.id]
|
||||
|
||||
# We want to hav the env_dict in the log ... but not password values
|
||||
env_dict_for_logging = env_dict.copy()
|
||||
for question in questions:
|
||||
# Or should it be more generally question.redact ?
|
||||
if question.type == "password":
|
||||
if f"YNH_APP_ARG_{question.id.upper()}" in env_dict_for_logging:
|
||||
del env_dict_for_logging[f"YNH_APP_ARG_{question.id.upper()}"]
|
||||
if question.id in env_dict_for_logging:
|
||||
del env_dict_for_logging[question.id]
|
||||
for option in options:
|
||||
# Or should it be more generally option.redact ?
|
||||
if option.type == "password":
|
||||
if f"YNH_APP_ARG_{option.id.upper()}" in env_dict_for_logging:
|
||||
del env_dict_for_logging[f"YNH_APP_ARG_{option.id.upper()}"]
|
||||
if option.id in env_dict_for_logging:
|
||||
del env_dict_for_logging[option.id]
|
||||
|
||||
operation_logger.extra.update({"env": env_dict_for_logging})
|
||||
|
||||
|
@ -1801,30 +1803,39 @@ class AppConfigPanel(ConfigPanel):
|
|||
entity_type = "app"
|
||||
save_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/settings.yml")
|
||||
config_path_tpl = os.path.join(APPS_SETTING_PATH, "{entity}/config_panel.toml")
|
||||
settings_must_be_defined: bool = True
|
||||
|
||||
def _run_action(self, action):
|
||||
env = {key: str(value) for key, value in self.new_values.items()}
|
||||
self._call_config_script(action, env=env)
|
||||
def _get_raw_settings(self, config: "ConfigPanelModel") -> "RawSettings":
|
||||
return self._call_config_script("show")
|
||||
|
||||
def _get_raw_settings(self):
|
||||
self.values = self._call_config_script("show")
|
||||
|
||||
def _apply(self):
|
||||
env = {key: str(value) for key, value in self.new_values.items()}
|
||||
def _apply(
|
||||
self,
|
||||
form: "FormModel",
|
||||
previous_settings: dict[str, Any],
|
||||
exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None,
|
||||
) -> None:
|
||||
env = {key: str(value) for key, value in form.dict().items()}
|
||||
return_content = self._call_config_script("apply", env=env)
|
||||
|
||||
# If the script returned validation error
|
||||
# raise a ValidationError exception using
|
||||
# the first key
|
||||
if return_content:
|
||||
for key, message in return_content.get("validation_errors").items():
|
||||
errors = return_content.get("validation_errors")
|
||||
if errors:
|
||||
for key, message in errors.items():
|
||||
raise YunohostValidationError(
|
||||
"app_argument_invalid",
|
||||
name=key,
|
||||
error=message,
|
||||
)
|
||||
|
||||
def _call_config_script(self, action, env=None):
|
||||
def _run_action(self, form: "FormModel", action_id: str) -> None:
|
||||
env = {key: str(value) for key, value in form.dict().items()}
|
||||
self._call_config_script(action_id, env=env)
|
||||
|
||||
def _call_config_script(
|
||||
self, action: str, env: Union[dict[str, Any], None] = None
|
||||
) -> dict[str, Any]:
|
||||
from yunohost.hook import hook_exec
|
||||
|
||||
if env is None:
|
||||
|
|
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):
|
||||
from lexicon.providers.auto import _relevant_provider_for_domain
|
||||
|
||||
registrar_infos = {
|
||||
registrar_infos = OrderedDict(
|
||||
{
|
||||
"name": m18n.n(
|
||||
"registrar_infos"
|
||||
), # This is meant to name the config panel section, for proper display in the webadmin
|
||||
"registrar": OrderedDict(
|
||||
{
|
||||
"readonly": True,
|
||||
"visible": False,
|
||||
"default": None,
|
||||
}
|
||||
),
|
||||
"infos": OrderedDict(
|
||||
{
|
||||
"type": "alert",
|
||||
"style": "info",
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
dns_zone = _get_dns_zone_for_domain(domain)
|
||||
|
||||
|
@ -519,61 +534,35 @@ def _get_registrar_config_section(domain):
|
|||
else:
|
||||
parent_domain_link = parent_domain
|
||||
|
||||
registrar_infos["registrar"] = OrderedDict(
|
||||
{
|
||||
"type": "alert",
|
||||
"style": "info",
|
||||
"ask": m18n.n(
|
||||
registrar_infos["registrar"]["default"] = "parent_domain"
|
||||
registrar_infos["infos"]["ask"] = m18n.n(
|
||||
"domain_dns_registrar_managed_in_parent_domain",
|
||||
parent_domain=parent_domain,
|
||||
parent_domain_link=parent_domain_link,
|
||||
),
|
||||
"value": "parent_domain",
|
||||
}
|
||||
)
|
||||
return OrderedDict(registrar_infos)
|
||||
return registrar_infos
|
||||
|
||||
# TODO big project, integrate yunohost's dynette as a registrar-like provider
|
||||
# TODO big project, integrate other dyndns providers such as netlib.re, or cf the list of dyndns providers supported by cloudron...
|
||||
if is_yunohost_dyndns_domain(dns_zone):
|
||||
registrar_infos["registrar"] = OrderedDict(
|
||||
{
|
||||
"type": "alert",
|
||||
"style": "success",
|
||||
"ask": m18n.n("domain_dns_registrar_yunohost"),
|
||||
"value": "yunohost",
|
||||
}
|
||||
)
|
||||
return OrderedDict(registrar_infos)
|
||||
registrar_infos["registrar"]["default"] = "yunohost"
|
||||
registrar_infos["infos"]["style"] = "success"
|
||||
registrar_infos["infos"]["ask"] = m18n.n("domain_dns_registrar_yunohost")
|
||||
|
||||
return registrar_infos
|
||||
elif is_special_use_tld(dns_zone):
|
||||
registrar_infos["registrar"] = OrderedDict(
|
||||
{
|
||||
"type": "alert",
|
||||
"style": "info",
|
||||
"ask": m18n.n("domain_dns_conf_special_use_tld"),
|
||||
"value": None,
|
||||
}
|
||||
)
|
||||
registrar_infos["infos"]["ask"] = m18n.n("domain_dns_conf_special_use_tld")
|
||||
|
||||
try:
|
||||
registrar = _relevant_provider_for_domain(dns_zone)[0]
|
||||
except ValueError:
|
||||
registrar_infos["registrar"] = OrderedDict(
|
||||
{
|
||||
"type": "alert",
|
||||
"style": "warning",
|
||||
"ask": m18n.n("domain_dns_registrar_not_supported"),
|
||||
"value": None,
|
||||
}
|
||||
)
|
||||
registrar_infos["registrar"]["default"] = None
|
||||
registrar_infos["infos"]["ask"] = m18n.n("domain_dns_registrar_not_supported")
|
||||
registrar_infos["infos"]["style"] = "warning"
|
||||
else:
|
||||
registrar_infos["registrar"] = OrderedDict(
|
||||
{
|
||||
"type": "alert",
|
||||
"style": "info",
|
||||
"ask": m18n.n("domain_dns_registrar_supported", registrar=registrar),
|
||||
"value": registrar,
|
||||
}
|
||||
registrar_infos["registrar"]["default"] = registrar
|
||||
registrar_infos["infos"]["ask"] = m18n.n(
|
||||
"domain_dns_registrar_supported", registrar=registrar
|
||||
)
|
||||
|
||||
TESTED_REGISTRARS = ["ovh", "gandi"]
|
||||
|
@ -601,7 +590,7 @@ def _get_registrar_config_section(domain):
|
|||
infos["optional"] = infos.get("optional", "False")
|
||||
registrar_infos.update(registrar_credentials)
|
||||
|
||||
return OrderedDict(registrar_infos)
|
||||
return registrar_infos
|
||||
|
||||
|
||||
def _get_registar_settings(domain):
|
||||
|
|
134
src/domain.py
134
src/domain.py
|
@ -19,7 +19,7 @@
|
|||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import TYPE_CHECKING, Any, List, Optional, Union
|
||||
from collections import OrderedDict
|
||||
from logging import getLogger
|
||||
|
||||
|
@ -47,6 +47,12 @@ from yunohost.utils.error import YunohostError, YunohostValidationError
|
|||
from yunohost.utils.dns import is_yunohost_dyndns_domain
|
||||
from yunohost.log import is_unit_operation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.typing import AbstractSetIntStr, MappingIntStrAny
|
||||
|
||||
from yunohost.utils.configpanel import RawConfig
|
||||
from yunohost.utils.form import FormModel
|
||||
|
||||
logger = getLogger("yunohost.domain")
|
||||
|
||||
DOMAIN_SETTINGS_DIR = "/etc/yunohost/domains"
|
||||
|
@ -649,98 +655,83 @@ class DomainConfigPanel(ConfigPanel):
|
|||
save_path_tpl = f"{DOMAIN_SETTINGS_DIR}/{{entity}}.yml"
|
||||
save_mode = "diff"
|
||||
|
||||
def get(self, key="", mode="classic"):
|
||||
result = super().get(key=key, mode=mode)
|
||||
|
||||
if mode == "full":
|
||||
for panel, section, option in self._iterate():
|
||||
# This injects:
|
||||
# i18n: domain_config_cert_renew_help
|
||||
# i18n: domain_config_default_app_help
|
||||
# i18n: domain_config_xmpp_help
|
||||
if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
|
||||
option["help"] = m18n.n(
|
||||
self.config["i18n"] + "_" + option["id"] + "_help"
|
||||
)
|
||||
return self.config
|
||||
|
||||
return result
|
||||
def _get_raw_config(self) -> "RawConfig":
|
||||
# TODO add mechanism to share some settings with other domains on the same zone
|
||||
raw_config = super()._get_raw_config()
|
||||
|
||||
def _get_raw_config(self):
|
||||
toml = super()._get_raw_config()
|
||||
any_filter = all(self.filter_key)
|
||||
panel_id, section_id, option_id = self.filter_key
|
||||
|
||||
toml["feature"]["xmpp"]["xmpp"]["default"] = (
|
||||
raw_config["feature"]["xmpp"]["xmpp"]["default"] = (
|
||||
1 if self.entity == _get_maindomain() else 0
|
||||
)
|
||||
|
||||
# Portal settings are only available on "topest" domains
|
||||
if _get_parent_domain_of(self.entity, topest=True) is not None:
|
||||
del toml["feature"]["portal"]
|
||||
del raw_config["feature"]["portal"]
|
||||
|
||||
# Optimize wether or not to load the DNS section,
|
||||
# e.g. we don't want to trigger the whole _get_registary_config_section
|
||||
# when just getting the current value from the feature section
|
||||
filter_key = self.filter_key.split(".") if self.filter_key != "" else []
|
||||
if not filter_key or filter_key[0] == "dns":
|
||||
if not any_filter or panel_id == "dns":
|
||||
from yunohost.dns import _get_registrar_config_section
|
||||
|
||||
toml["dns"]["registrar"] = _get_registrar_config_section(self.entity)
|
||||
|
||||
# FIXME: Ugly hack to save the registar id/value and reinject it in _get_raw_settings ...
|
||||
self.registar_id = toml["dns"]["registrar"]["registrar"]["value"]
|
||||
del toml["dns"]["registrar"]["registrar"]["value"]
|
||||
raw_config["dns"]["registrar"] = _get_registrar_config_section(self.entity)
|
||||
|
||||
# Cert stuff
|
||||
if not filter_key or filter_key[0] == "cert":
|
||||
if not any_filter or panel_id == "cert":
|
||||
from yunohost.certificate import certificate_status
|
||||
|
||||
status = certificate_status([self.entity], full=True)["certificates"][
|
||||
self.entity
|
||||
]
|
||||
|
||||
toml["cert"]["cert"]["cert_summary"]["style"] = status["style"]
|
||||
raw_config["cert"]["cert"]["cert_summary"]["style"] = status["style"]
|
||||
|
||||
# i18n: domain_config_cert_summary_expired
|
||||
# i18n: domain_config_cert_summary_selfsigned
|
||||
# i18n: domain_config_cert_summary_abouttoexpire
|
||||
# i18n: domain_config_cert_summary_ok
|
||||
# i18n: domain_config_cert_summary_letsencrypt
|
||||
toml["cert"]["cert"]["cert_summary"]["ask"] = m18n.n(
|
||||
raw_config["cert"]["cert"]["cert_summary"]["ask"] = m18n.n(
|
||||
f"domain_config_cert_summary_{status['summary']}"
|
||||
)
|
||||
|
||||
# FIXME: Ugly hack to save the cert status and reinject it in _get_raw_settings ...
|
||||
self.cert_status = status
|
||||
for option_id, status_key in [
|
||||
("cert_validity", "validity"),
|
||||
("cert_issuer", "CA_type"),
|
||||
("acme_eligible", "ACME_eligible"),
|
||||
# FIXME not sure why "summary" was injected in settings values
|
||||
# ("summary", "summary")
|
||||
]:
|
||||
raw_config["cert"]["cert"][option_id]["default"] = status[status_key]
|
||||
|
||||
return toml
|
||||
# Other specific strings used in config panels
|
||||
# i18n: domain_config_cert_renew_help
|
||||
|
||||
def _get_raw_settings(self):
|
||||
# TODO add mechanism to share some settings with other domains on the same zone
|
||||
super()._get_raw_settings()
|
||||
return raw_config
|
||||
|
||||
# FIXME: Ugly hack to save the registar id/value and reinject it in _get_raw_settings ...
|
||||
filter_key = self.filter_key.split(".") if self.filter_key != "" else []
|
||||
if not filter_key or filter_key[0] == "dns":
|
||||
self.values["registrar"] = self.registar_id
|
||||
def _apply(
|
||||
self,
|
||||
form: "FormModel",
|
||||
previous_settings: dict[str, Any],
|
||||
exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None,
|
||||
) -> None:
|
||||
next_settings = {
|
||||
k: v for k, v in form.dict().items() if previous_settings.get(k) != v
|
||||
}
|
||||
|
||||
# FIXME: Ugly hack to save the cert status and reinject it in _get_raw_settings ...
|
||||
if not filter_key or filter_key[0] == "cert":
|
||||
self.values["cert_validity"] = self.cert_status["validity"]
|
||||
self.values["cert_issuer"] = self.cert_status["CA_type"]
|
||||
self.values["acme_eligible"] = self.cert_status["ACME_eligible"]
|
||||
self.values["summary"] = self.cert_status["summary"]
|
||||
|
||||
def _apply(self):
|
||||
if (
|
||||
"default_app" in self.future_values
|
||||
and self.future_values["default_app"] != self.values["default_app"]
|
||||
):
|
||||
if "default_app" in next_settings:
|
||||
from yunohost.app import app_ssowatconf, app_map
|
||||
|
||||
if "/" in app_map(raw=True).get(self.entity, {}):
|
||||
raise YunohostValidationError(
|
||||
"app_make_default_location_already_used",
|
||||
app=self.future_values["default_app"],
|
||||
app=next_settings["default_app"],
|
||||
domain=self.entity,
|
||||
other_app=app_map(raw=True)[self.entity]["/"]["id"],
|
||||
)
|
||||
|
@ -753,8 +744,7 @@ class DomainConfigPanel(ConfigPanel):
|
|||
"portal_theme",
|
||||
]
|
||||
if _get_parent_domain_of(self.entity, topest=True) is None and any(
|
||||
option in self.future_values
|
||||
and self.new_values[option] != self.values.get(option)
|
||||
option in next_settings
|
||||
for option in portal_options
|
||||
):
|
||||
from yunohost.portal import PORTAL_SETTINGS_DIR
|
||||
|
@ -762,12 +752,11 @@ class DomainConfigPanel(ConfigPanel):
|
|||
# Portal options are also saved in a `domain.portal.yml` file
|
||||
# that can be read by the portal API.
|
||||
# FIXME remove those from the config panel saved values?
|
||||
portal_values = {
|
||||
option: self.future_values[option] for option in portal_options
|
||||
}
|
||||
|
||||
portal_values = form.dict(include=set(portal_options))
|
||||
|
||||
portal_settings_path = Path(f"{PORTAL_SETTINGS_DIR}/{self.entity}.json")
|
||||
portal_settings = {"apps": {}}
|
||||
portal_settings: dict[str, Any] = {"apps": {}}
|
||||
|
||||
if portal_settings_path.exists():
|
||||
portal_settings.update(read_json(str(portal_settings_path)))
|
||||
|
@ -778,38 +767,21 @@ class DomainConfigPanel(ConfigPanel):
|
|||
str(portal_settings_path), portal_settings, sort_keys=True, indent=4
|
||||
)
|
||||
|
||||
super()._apply()
|
||||
super()._apply(form, previous_settings)
|
||||
|
||||
# Reload ssowat if default app changed
|
||||
if (
|
||||
"default_app" in self.future_values
|
||||
and self.future_values["default_app"] != self.values["default_app"]
|
||||
):
|
||||
if "default_app" in next_settings:
|
||||
app_ssowatconf()
|
||||
|
||||
stuff_to_regen_conf = []
|
||||
if (
|
||||
"xmpp" in self.future_values
|
||||
and self.future_values["xmpp"] != self.values["xmpp"]
|
||||
):
|
||||
stuff_to_regen_conf.append("nginx")
|
||||
stuff_to_regen_conf.append("metronome")
|
||||
stuff_to_regen_conf = set()
|
||||
if "xmpp" in next_settings:
|
||||
stuff_to_regen_conf.update({"nginx", "metronome"})
|
||||
|
||||
if (
|
||||
"mail_in" in self.future_values
|
||||
and self.future_values["mail_in"] != self.values["mail_in"]
|
||||
) or (
|
||||
"mail_out" in self.future_values
|
||||
and self.future_values["mail_out"] != self.values["mail_out"]
|
||||
):
|
||||
if "nginx" not in stuff_to_regen_conf:
|
||||
stuff_to_regen_conf.append("nginx")
|
||||
stuff_to_regen_conf.append("postfix")
|
||||
stuff_to_regen_conf.append("dovecot")
|
||||
stuff_to_regen_conf.append("rspamd")
|
||||
if "mail_in" in next_settings or "mail_out" in next_settings:
|
||||
stuff_to_regen_conf.update({"nginx", "postfix", "dovecot", "rspamd"})
|
||||
|
||||
if stuff_to_regen_conf:
|
||||
regen_conf(names=stuff_to_regen_conf)
|
||||
regen_conf(names=list(stuff_to_regen_conf))
|
||||
|
||||
|
||||
def domain_action_run(domain, action, args=None):
|
||||
|
|
|
@ -469,7 +469,7 @@ class OperationLogger:
|
|||
This class record logs and metadata like context or start time/end time.
|
||||
"""
|
||||
|
||||
_instances: List[object] = []
|
||||
_instances: List["OperationLogger"] = []
|
||||
|
||||
def __init__(self, operation, related_to=None, **kwargs):
|
||||
# TODO add a way to not save password on app installation
|
||||
|
|
121
src/settings.py
121
src/settings.py
|
@ -19,16 +19,30 @@
|
|||
import os
|
||||
import subprocess
|
||||
from logging import getLogger
|
||||
from typing import TYPE_CHECKING, Any, Union
|
||||
|
||||
from moulinette import m18n
|
||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||
from yunohost.utils.configpanel import ConfigPanel
|
||||
from yunohost.utils.configpanel import ConfigPanel, parse_filter_key
|
||||
from yunohost.utils.form import BaseOption
|
||||
from yunohost.regenconf import regen_conf
|
||||
from yunohost.firewall import firewall_reload
|
||||
from yunohost.log import is_unit_operation
|
||||
from yunohost.utils.legacy import translate_legacy_settings_to_configpanel_settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from yunohost.log import OperationLogger
|
||||
|
||||
from pydantic.typing import AbstractSetIntStr, MappingIntStrAny
|
||||
|
||||
from yunohost.utils.configpanel import (
|
||||
ConfigPanelGetMode,
|
||||
ConfigPanelModel,
|
||||
RawConfig,
|
||||
RawSettings,
|
||||
)
|
||||
from yunohost.utils.form import FormModel
|
||||
|
||||
logger = getLogger("yunohost.settings")
|
||||
|
||||
SETTINGS_PATH = "/etc/yunohost/settings.yml"
|
||||
|
@ -120,47 +134,49 @@ class SettingsConfigPanel(ConfigPanel):
|
|||
entity_type = "global"
|
||||
save_path_tpl = SETTINGS_PATH
|
||||
save_mode = "diff"
|
||||
virtual_settings = ["root_password", "root_password_confirm", "passwordless_sudo"]
|
||||
virtual_settings = {"root_password", "root_password_confirm", "passwordless_sudo"}
|
||||
|
||||
def __init__(self, config_path=None, save_path=None, creation=False):
|
||||
def __init__(self, config_path=None, save_path=None, creation=False) -> None:
|
||||
super().__init__("settings")
|
||||
|
||||
def get(self, key="", mode="classic"):
|
||||
def get(
|
||||
self, key: Union[str, None] = None, mode: "ConfigPanelGetMode" = "classic"
|
||||
) -> Any:
|
||||
result = super().get(key=key, mode=mode)
|
||||
|
||||
if mode == "full":
|
||||
for panel, section, option in self._iterate():
|
||||
if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
|
||||
option["help"] = m18n.n(
|
||||
self.config["i18n"] + "_" + option["id"] + "_help"
|
||||
)
|
||||
return self.config
|
||||
|
||||
# Dirty hack to let settings_get() to work from a python script
|
||||
if isinstance(result, str) and result in ["True", "False"]:
|
||||
result = bool(result == "True")
|
||||
|
||||
return result
|
||||
|
||||
def reset(self, key="", operation_logger=None):
|
||||
self.filter_key = key
|
||||
def reset(
|
||||
self,
|
||||
key: Union[str, None] = None,
|
||||
operation_logger: Union["OperationLogger", None] = None,
|
||||
) -> None:
|
||||
self.filter_key = parse_filter_key(key)
|
||||
|
||||
# Read config panel toml
|
||||
self._get_config_panel()
|
||||
self.config, self.form = self._get_config_panel(prevalidate=True)
|
||||
|
||||
if not self.config:
|
||||
raise YunohostValidationError("config_no_panel")
|
||||
# FIXME find a better way to exclude previous settings
|
||||
previous_settings = self.form.dict()
|
||||
|
||||
# Replace all values with default values
|
||||
self.values = self._get_default_values()
|
||||
for option in self.config.options:
|
||||
if not option.readonly and (
|
||||
option.optional or option.default not in {None, ""}
|
||||
):
|
||||
# FIXME Mypy complains about option.default not being a valid type for normalize but this should be ok
|
||||
self.form[option.id] = option.normalize(option.default, option) # type: ignore
|
||||
|
||||
BaseOption.operation_logger = operation_logger
|
||||
# FIXME Not sure if this is need (redact call to operation logger does it on all the instances)
|
||||
# BaseOption.operation_logger = operation_logger
|
||||
|
||||
if operation_logger:
|
||||
operation_logger.start()
|
||||
|
||||
try:
|
||||
self._apply()
|
||||
self._apply(self.form, previous_settings)
|
||||
except YunohostError:
|
||||
raise
|
||||
# Script got manually interrupted ...
|
||||
|
@ -178,10 +194,12 @@ class SettingsConfigPanel(ConfigPanel):
|
|||
raise
|
||||
|
||||
logger.success(m18n.n("global_settings_reset_success"))
|
||||
|
||||
if operation_logger:
|
||||
operation_logger.success()
|
||||
|
||||
def _get_raw_config(self):
|
||||
toml = super()._get_raw_config()
|
||||
def _get_raw_config(self) -> "RawConfig":
|
||||
raw_config = super()._get_raw_config()
|
||||
|
||||
# Dynamic choice list for portal themes
|
||||
THEMEDIR = "/usr/share/ssowat/portal/assets/themes/"
|
||||
|
@ -189,42 +207,40 @@ class SettingsConfigPanel(ConfigPanel):
|
|||
themes = [d for d in os.listdir(THEMEDIR) if os.path.isdir(THEMEDIR + d)]
|
||||
except Exception:
|
||||
themes = ["unsplash", "vapor", "light", "default", "clouds"]
|
||||
toml["misc"]["portal"]["portal_theme"]["choices"] = themes
|
||||
raw_config["misc"]["portal"]["portal_theme"]["choices"] = themes
|
||||
|
||||
return toml
|
||||
return raw_config
|
||||
|
||||
def _get_raw_settings(self):
|
||||
super()._get_raw_settings()
|
||||
def _get_raw_settings(self, config: "ConfigPanelModel") -> "RawSettings":
|
||||
raw_settings = super()._get_raw_settings(config)
|
||||
|
||||
# Specific logic for those settings who are "virtual" settings
|
||||
# and only meant to have a custom setter mapped to tools_rootpw
|
||||
self.values["root_password"] = ""
|
||||
self.values["root_password_confirm"] = ""
|
||||
raw_settings["root_password"] = ""
|
||||
raw_settings["root_password_confirm"] = ""
|
||||
|
||||
# Specific logic for virtual setting "passwordless_sudo"
|
||||
try:
|
||||
from yunohost.utils.ldap import _get_ldap_interface
|
||||
|
||||
ldap = _get_ldap_interface()
|
||||
self.values["passwordless_sudo"] = "!authenticate" in ldap.search(
|
||||
raw_settings["passwordless_sudo"] = "!authenticate" in ldap.search(
|
||||
"ou=sudo", "cn=admins", ["sudoOption"]
|
||||
)[0].get("sudoOption", [])
|
||||
except Exception:
|
||||
self.values["passwordless_sudo"] = False
|
||||
raw_settings["passwordless_sudo"] = False
|
||||
|
||||
def _apply(self):
|
||||
root_password = self.new_values.pop("root_password", None)
|
||||
root_password_confirm = self.new_values.pop("root_password_confirm", None)
|
||||
passwordless_sudo = self.new_values.pop("passwordless_sudo", None)
|
||||
return raw_settings
|
||||
|
||||
self.values = {
|
||||
k: v for k, v in self.values.items() if k not in self.virtual_settings
|
||||
}
|
||||
self.new_values = {
|
||||
k: v for k, v in self.new_values.items() if k not in self.virtual_settings
|
||||
}
|
||||
|
||||
assert all(v not in self.future_values for v in self.virtual_settings)
|
||||
def _apply(
|
||||
self,
|
||||
form: "FormModel",
|
||||
previous_settings: dict[str, Any],
|
||||
exclude: Union["AbstractSetIntStr", "MappingIntStrAny", None] = None,
|
||||
) -> None:
|
||||
root_password = form.get("root_password", None)
|
||||
root_password_confirm = form.get("root_password_confirm", None)
|
||||
passwordless_sudo = form.get("passwordless_sudo", None)
|
||||
|
||||
if root_password and root_password.strip():
|
||||
if root_password != root_password_confirm:
|
||||
|
@ -243,15 +259,20 @@ class SettingsConfigPanel(ConfigPanel):
|
|||
{"sudoOption": ["!authenticate"] if passwordless_sudo else []},
|
||||
)
|
||||
|
||||
super()._apply()
|
||||
|
||||
settings = {
|
||||
k: v for k, v in self.future_values.items() if self.values.get(k) != v
|
||||
# First save settings except virtual + default ones
|
||||
super()._apply(form, previous_settings, exclude=self.virtual_settings)
|
||||
next_settings = {
|
||||
k: v
|
||||
for k, v in form.dict(exclude=self.virtual_settings).items()
|
||||
if previous_settings.get(k) != v
|
||||
}
|
||||
for setting_name, value in settings.items():
|
||||
|
||||
for setting_name, value in next_settings.items():
|
||||
try:
|
||||
# FIXME not sure to understand why we need the previous value if
|
||||
# updated_settings has already been filtered
|
||||
trigger_post_change_hook(
|
||||
setting_name, self.values.get(setting_name), value
|
||||
setting_name, previous_settings.get(setting_name), value
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Post-change hook for setting failed : {e}")
|
||||
|
|
|
@ -125,9 +125,9 @@ def test_app_config_get_nonexistentstuff(config_app):
|
|||
with pytest.raises(YunohostValidationError):
|
||||
app_config_get(config_app, "main.components.nonexistent")
|
||||
|
||||
app_setting(config_app, "boolean", delete=True)
|
||||
app_setting(config_app, "number", delete=True)
|
||||
with pytest.raises(YunohostError):
|
||||
app_config_get(config_app, "main.components.boolean")
|
||||
app_config_get(config_app, "main.components.number")
|
||||
|
||||
|
||||
def test_app_config_regular_setting(config_app):
|
||||
|
|
|
@ -49,19 +49,19 @@ def test_registrar_list_integrity():
|
|||
|
||||
|
||||
def test_magic_guess_registrar_weird_domain():
|
||||
assert _get_registrar_config_section("yolo.tld")["registrar"]["value"] is None
|
||||
assert _get_registrar_config_section("yolo.tld")["registrar"]["default"] is None
|
||||
|
||||
|
||||
def test_magic_guess_registrar_ovh():
|
||||
assert (
|
||||
_get_registrar_config_section("yolo.yunohost.org")["registrar"]["value"]
|
||||
_get_registrar_config_section("yolo.yunohost.org")["registrar"]["default"]
|
||||
== "ovh"
|
||||
)
|
||||
|
||||
|
||||
def test_magic_guess_registrar_yunodyndns():
|
||||
assert (
|
||||
_get_registrar_config_section("yolo.nohost.me")["registrar"]["value"]
|
||||
_get_registrar_config_section("yolo.nohost.me")["registrar"]["default"]
|
||||
== "yunohost"
|
||||
)
|
||||
|
||||
|
|
|
@ -11,22 +11,23 @@ from typing import Any, Literal, Sequence, TypedDict, Union
|
|||
|
||||
from _pytest.mark.structures import ParameterSet
|
||||
|
||||
|
||||
from moulinette import Moulinette
|
||||
from yunohost import app, domain, user
|
||||
from yunohost.utils.form import (
|
||||
OPTIONS,
|
||||
FORBIDDEN_PASSWORD_CHARS,
|
||||
READONLY_TYPES,
|
||||
ask_questions_and_parse_answers,
|
||||
BaseChoicesOption,
|
||||
BaseInputOption,
|
||||
BaseReadonlyOption,
|
||||
PasswordOption,
|
||||
DomainOption,
|
||||
WebPathOption,
|
||||
BooleanOption,
|
||||
FileOption,
|
||||
evaluate_simple_js_expression,
|
||||
)
|
||||
from yunohost.utils import form
|
||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||
|
||||
|
||||
|
@ -95,6 +96,12 @@ def patch_with_tty():
|
|||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_cli_retries():
|
||||
with patch.object(form, "MAX_RETRIES", 0):
|
||||
yield
|
||||
|
||||
|
||||
# ╭───────────────────────────────────────────────────────╮
|
||||
# │ ╭─╴╭─╴┌─╴╭╮╷╭─┐┌─╮╶┬╴╭─╮╭─╴ │
|
||||
# │ ╰─╮│ ├─╴│││├─┤├┬╯ │ │ │╰─╮ │
|
||||
|
@ -378,8 +385,8 @@ def _fill_or_prompt_one_option(raw_option, intake):
|
|||
options = {id_: raw_option}
|
||||
answers = {id_: intake} if intake is not None else {}
|
||||
|
||||
option = ask_questions_and_parse_answers(options, answers)[0]
|
||||
return (option, option.value if isinstance(option, BaseInputOption) else None)
|
||||
options, form = ask_questions_and_parse_answers(options, answers)
|
||||
return (options[0], form[id_] if isinstance(options[0], BaseInputOption) else None)
|
||||
|
||||
|
||||
def _test_value_is_expected_output(value, expected_output):
|
||||
|
@ -406,6 +413,7 @@ def _test_intake_may_fail(raw_option, intake, expected_output):
|
|||
_test_intake(raw_option, intake, expected_output)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("patch_cli_retries") # To avoid chain error logging
|
||||
class BaseTest:
|
||||
raw_option: dict[str, Any] = {}
|
||||
prefill: dict[Literal["raw_option", "prefill", "intake"], Any]
|
||||
|
@ -436,6 +444,13 @@ class BaseTest:
|
|||
@classmethod
|
||||
def _test_basic_attrs(self):
|
||||
raw_option = self.get_raw_option(optional=True)
|
||||
|
||||
if raw_option["type"] in READONLY_TYPES:
|
||||
del raw_option["optional"]
|
||||
|
||||
if raw_option["type"] == "select":
|
||||
raw_option["choices"] = ["one"]
|
||||
|
||||
id_ = raw_option["id"]
|
||||
option, value = _fill_or_prompt_one_option(raw_option, None)
|
||||
|
||||
|
@ -444,7 +459,7 @@ class BaseTest:
|
|||
assert isinstance(option, OPTIONS[raw_option["type"]])
|
||||
assert option.type == raw_option["type"]
|
||||
assert option.id == id_
|
||||
assert option.ask == {"en": id_}
|
||||
assert option.ask == id_
|
||||
assert option.readonly is (True if is_special_readonly_option else False)
|
||||
assert option.visible is True
|
||||
# assert option.bind is None
|
||||
|
@ -481,6 +496,7 @@ class BaseTest:
|
|||
base_raw_option = prefill_data["raw_option"]
|
||||
prefill = prefill_data["prefill"]
|
||||
|
||||
# FIXME could patch prompt with prefill if we switch to "do not apply default if value is None|''"
|
||||
with patch_prompt("") as prompt:
|
||||
raw_option = self.get_raw_option(
|
||||
raw_option=base_raw_option,
|
||||
|
@ -489,7 +505,7 @@ class BaseTest:
|
|||
)
|
||||
option, value = _fill_or_prompt_one_option(raw_option, None)
|
||||
|
||||
expected_message = option.ask["en"]
|
||||
expected_message = option.ask
|
||||
choices = []
|
||||
|
||||
if isinstance(option, BaseChoicesOption):
|
||||
|
@ -506,7 +522,7 @@ class BaseTest:
|
|||
prefill=prefill,
|
||||
is_multiline=option.type == "text",
|
||||
autocomplete=choices,
|
||||
help=option.help["en"],
|
||||
help=option.help,
|
||||
)
|
||||
|
||||
def test_scenarios(self, intake, expected_output, raw_option, data):
|
||||
|
@ -551,10 +567,10 @@ class TestDisplayText(BaseTest):
|
|||
ask_questions_and_parse_answers({_id: raw_option}, answers)
|
||||
else:
|
||||
with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
|
||||
options = ask_questions_and_parse_answers(
|
||||
options, form = ask_questions_and_parse_answers(
|
||||
{_id: raw_option}, answers
|
||||
)
|
||||
assert stdout.getvalue() == f"{options[0].ask['en']}\n"
|
||||
assert stdout.getvalue() == f"{options[0].ask}\n"
|
||||
|
||||
|
||||
# ╭───────────────────────────────────────────────────────╮
|
||||
|
@ -583,9 +599,7 @@ class TestAlert(TestDisplayText):
|
|||
(None, None, {"ask": "Some text\na new line"}),
|
||||
(None, None, {"ask": {"en": "Some text\na new line", "fr": "Un peu de texte\nune nouvelle ligne"}}),
|
||||
*[(None, None, {"ask": "question", "style": style}) for style in ("success", "info", "warning", "danger")],
|
||||
*xpass(scenarios=[
|
||||
(None, None, {"ask": "question", "style": "nimp"}),
|
||||
], reason="Should fail, wrong style"),
|
||||
(None, YunohostError, {"ask": "question", "style": "nimp"}),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -604,10 +618,10 @@ class TestAlert(TestDisplayText):
|
|||
)
|
||||
else:
|
||||
with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
|
||||
options = ask_questions_and_parse_answers(
|
||||
options, form = ask_questions_and_parse_answers(
|
||||
{"display_text_id": raw_option}, answers
|
||||
)
|
||||
ask = options[0].ask["en"]
|
||||
ask = options[0].ask
|
||||
if style in colors:
|
||||
color = colors[style]
|
||||
title = style.title() + (":" if style != "success" else "!")
|
||||
|
@ -643,11 +657,15 @@ class TestString(BaseTest):
|
|||
scenarios = [
|
||||
*nones(None, "", output=""),
|
||||
# basic typed values
|
||||
*unchanged(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}), # FIXME should output as str?
|
||||
(False, "False"),
|
||||
(True, "True"),
|
||||
(0, "0"),
|
||||
(1, "1"),
|
||||
(-1, "-1"),
|
||||
(1337, "1337"),
|
||||
(13.37, "13.37"),
|
||||
*all_fails([], ["one"], {}),
|
||||
*unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
|
||||
*xpass(scenarios=[
|
||||
([], []),
|
||||
], reason="Should fail"),
|
||||
# test strip
|
||||
("value", "value"),
|
||||
("value\n", "value"),
|
||||
|
@ -660,7 +678,7 @@ class TestString(BaseTest):
|
|||
(" ##value \n \tvalue\n ", "##value \n \tvalue"),
|
||||
], reason=r"should fail or without `\n`?"),
|
||||
# readonly
|
||||
("overwrite", "expected value", {"readonly": True, "current_value": "expected value"}),
|
||||
("overwrite", "expected value", {"readonly": True, "default": "expected value"}), # FIXME do we want to fail instead?
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -680,11 +698,15 @@ class TestText(BaseTest):
|
|||
scenarios = [
|
||||
*nones(None, "", output=""),
|
||||
# basic typed values
|
||||
*unchanged(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}), # FIXME should fail or output as str?
|
||||
(False, "False"),
|
||||
(True, "True"),
|
||||
(0, "0"),
|
||||
(1, "1"),
|
||||
(-1, "-1"),
|
||||
(1337, "1337"),
|
||||
(13.37, "13.37"),
|
||||
*all_fails([], ["one"], {}),
|
||||
*unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
|
||||
*xpass(scenarios=[
|
||||
([], [])
|
||||
], reason="Should fail"),
|
||||
("value", "value"),
|
||||
("value\n value", "value\n value"),
|
||||
# test no strip
|
||||
|
@ -697,7 +719,7 @@ class TestText(BaseTest):
|
|||
(r" ##value \n \tvalue\n ", r"##value \n \tvalue\n"),
|
||||
], reason="Should not be stripped"),
|
||||
# readonly
|
||||
("overwrite", "expected value", {"readonly": True, "current_value": "expected value"}),
|
||||
("overwrite", "expected value", {"readonly": True, "default": "expected value"}),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -715,11 +737,11 @@ class TestPassword(BaseTest):
|
|||
}
|
||||
# fmt: off
|
||||
scenarios = [
|
||||
*all_fails(False, True, 0, 1, -1, 1337, 13.37, raw_option={"optional": True}, error=TypeError), # FIXME those fails with TypeError
|
||||
*all_fails(False, True, 0, 1, -1, 1337, 13.37, raw_option={"optional": True}),
|
||||
*all_fails([], ["one"], {}, raw_option={"optional": True}, error=AttributeError), # FIXME those fails with AttributeError
|
||||
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
|
||||
*nones(None, "", output=""),
|
||||
("s3cr3t!!", FAIL, {"default": "SUPAs3cr3t!!"}), # default is forbidden
|
||||
("s3cr3t!!", YunohostError, {"default": "SUPAs3cr3t!!"}), # default is forbidden
|
||||
*xpass(scenarios=[
|
||||
("s3cr3t!!", "s3cr3t!!", {"example": "SUPAs3cr3t!!"}), # example is forbidden
|
||||
], reason="Should fail; example is forbidden"),
|
||||
|
@ -729,9 +751,9 @@ class TestPassword(BaseTest):
|
|||
], reason="Should output exactly the same"),
|
||||
("s3cr3t!!", "s3cr3t!!"),
|
||||
("secret", FAIL),
|
||||
*[("supersecret" + char, FAIL) for char in PasswordOption.forbidden_chars], # FIXME maybe add ` \n` to the list?
|
||||
*[("supersecret" + char, FAIL) for char in FORBIDDEN_PASSWORD_CHARS], # FIXME maybe add ` \n` to the list?
|
||||
# readonly
|
||||
("s3cr3t!!", YunohostError, {"readonly": True, "current_value": "isforbidden"}), # readonly is forbidden
|
||||
("s3cr3t!!", YunohostError, {"readonly": True}), # readonly is forbidden
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -744,35 +766,31 @@ class TestPassword(BaseTest):
|
|||
class TestColor(BaseTest):
|
||||
raw_option = {"type": "color", "id": "color_id"}
|
||||
prefill = {
|
||||
"raw_option": {"default": "#ff0000"},
|
||||
"prefill": "#ff0000",
|
||||
# "intake": "#ff00ff",
|
||||
"raw_option": {"default": "red"},
|
||||
"prefill": "red",
|
||||
}
|
||||
# fmt: off
|
||||
scenarios = [
|
||||
*all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
|
||||
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
|
||||
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
|
||||
*nones(None, "", output=""),
|
||||
# custom valid
|
||||
("#000000", "#000000"),
|
||||
(" #fe1 ", "#fe1"),
|
||||
("#000000", "#000"),
|
||||
("#000", "#000"),
|
||||
("#fe100", "#fe100"),
|
||||
(" #fe100 ", "#fe100"),
|
||||
("#ABCDEF", "#ABCDEF"),
|
||||
("#ABCDEF", "#abcdef"),
|
||||
('1337', "#1337"), # rgba=(17, 51, 51, 0.47)
|
||||
("000000", "#000"),
|
||||
("#feaf", "#fea"), # `#feaf` is `#fea` with alpha at `f|100%` -> equivalent to `#fea`
|
||||
# named
|
||||
("red", "#f00"),
|
||||
("yellow", "#ff0"),
|
||||
# custom fail
|
||||
*xpass(scenarios=[
|
||||
("#feaf", "#feaf"),
|
||||
], reason="Should fail; not a legal color value"),
|
||||
("000000", FAIL),
|
||||
("#12", FAIL),
|
||||
("#gggggg", FAIL),
|
||||
("#01010101af", FAIL),
|
||||
*xfail(scenarios=[
|
||||
("red", "#ff0000"),
|
||||
("yellow", "#ffff00"),
|
||||
], reason="Should work with pydantic"),
|
||||
# readonly
|
||||
("#ffff00", "#fe100", {"readonly": True, "current_value": "#fe100"}),
|
||||
("#ffff00", "#000", {"readonly": True, "default": "#000"}),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -796,10 +814,8 @@ class TestNumber(BaseTest):
|
|||
|
||||
*nones(None, "", output=None),
|
||||
*unchanged(0, 1, -1, 1337),
|
||||
*xpass(scenarios=[(False, False)], reason="should fail or output as `0`"),
|
||||
*xpass(scenarios=[(True, True)], reason="should fail or output as `1`"),
|
||||
*all_as("0", 0, output=0),
|
||||
*all_as("1", 1, output=1),
|
||||
*all_as(False, "0", 0, output=0), # FIXME should `False` fail instead?
|
||||
*all_as(True, "1", 1, output=1), # FIXME should `True` fail instead?
|
||||
*all_as("1337", 1337, output=1337),
|
||||
*xfail(scenarios=[
|
||||
("-1", -1)
|
||||
|
@ -814,7 +830,7 @@ class TestNumber(BaseTest):
|
|||
(-10, -10, {"default": 10}),
|
||||
(-10, -10, {"default": 10, "optional": True}),
|
||||
# readonly
|
||||
(1337, 10000, {"readonly": True, "current_value": 10000}),
|
||||
(1337, 10000, {"readonly": True, "default": "10000"}),
|
||||
]
|
||||
# fmt: on
|
||||
# FIXME should `step` be some kind of "multiple of"?
|
||||
|
@ -839,14 +855,20 @@ class TestBoolean(BaseTest):
|
|||
*all_fails("none", "None"), # FIXME should output as `0` (default) like other none values when required?
|
||||
*all_as(None, "", output=0, raw_option={"optional": True}), # FIXME should output as `None`?
|
||||
*all_as("none", "None", output=None, raw_option={"optional": True}),
|
||||
# FIXME even if default is explicity `None|""`, it ends up with class_default `0`
|
||||
*all_as(None, "", output=0, raw_option={"default": None}), # FIXME this should fail, default is `None`
|
||||
*all_as(None, "", output=0, raw_option={"optional": True, "default": None}), # FIXME even if default is explicity None, it ends up with class_default
|
||||
*all_as(None, "", output=0, raw_option={"default": ""}), # FIXME this should fail, default is `""`
|
||||
*all_as(None, "", output=0, raw_option={"optional": True, "default": ""}), # FIXME even if default is explicity None, it ends up with class_default
|
||||
# With "none" behavior is ok
|
||||
*all_fails(None, "", raw_option={"default": "none"}),
|
||||
*all_as(None, "", output=None, raw_option={"optional": True, "default": "none"}),
|
||||
{
|
||||
"raw_options": [
|
||||
{"default": None},
|
||||
{"default": ""},
|
||||
{"default": "none"},
|
||||
{"default": "None"}
|
||||
],
|
||||
"scenarios": [
|
||||
# All none values fails if default is overriden
|
||||
*all_fails(None, "", "none", "None"),
|
||||
# All none values ends up as None if default is overriden
|
||||
*all_as(None, "", "none", "None", output=None, raw_option={"optional": True}),
|
||||
]
|
||||
},
|
||||
# Unhandled types should fail
|
||||
*all_fails(1337, "1337", "string", [], "[]", ",", "one,two"),
|
||||
*all_fails(1337, "1337", "string", [], "[]", ",", "one,two", {"optional": True}),
|
||||
|
@ -879,7 +901,7 @@ class TestBoolean(BaseTest):
|
|||
"scenarios": all_fails("", "y", "n", error=AssertionError),
|
||||
},
|
||||
# readonly
|
||||
(1, 0, {"readonly": True, "current_value": 0}),
|
||||
(1, 0, {"readonly": True, "default": 0}),
|
||||
]
|
||||
|
||||
|
||||
|
@ -896,8 +918,12 @@ class TestDate(BaseTest):
|
|||
}
|
||||
# fmt: off
|
||||
scenarios = [
|
||||
*all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
|
||||
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
|
||||
# Those passes since False|True are parsed as 0|1 then int|float are considered a timestamp in seconds which ends up as default Unix date
|
||||
*all_as(False, True, 0, 1, 1337, 13.37, "0", "1", "1337", "13.37", output="1970-01-01"),
|
||||
# Those are negative one second timestamp ending up as Unix date - 1 sec (so day change)
|
||||
*all_as(-1, "-1", output="1969-12-31"),
|
||||
*all_fails([], ["one"], {}, raw_option={"optional": True}),
|
||||
*all_fails("none", "_none", "False", "True", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
|
||||
*nones(None, "", output=""),
|
||||
# custom valid
|
||||
("2070-12-31", "2070-12-31"),
|
||||
|
@ -906,18 +932,16 @@ class TestDate(BaseTest):
|
|||
("2025-06-15T13:45:30", "2025-06-15"),
|
||||
("2025-06-15 13:45:30", "2025-06-15")
|
||||
], reason="iso date repr should be valid and extra data striped"),
|
||||
*xfail(scenarios=[
|
||||
(1749938400, "2025-06-15"),
|
||||
(1749938400.0, "2025-06-15"),
|
||||
("1749938400", "2025-06-15"),
|
||||
("1749938400.0", "2025-06-15"),
|
||||
], reason="timestamp could be an accepted value"),
|
||||
(1749938400, "2025-06-14"),
|
||||
(1749938400.0, "2025-06-14"),
|
||||
("1749938400", "2025-06-14"),
|
||||
("1749938400.0", "2025-06-14"),
|
||||
# custom invalid
|
||||
("29-12-2070", FAIL),
|
||||
("12-01-10", FAIL),
|
||||
("2022-02-29", FAIL),
|
||||
# readonly
|
||||
("2070-12-31", "2024-02-29", {"readonly": True, "current_value": "2024-02-29"}),
|
||||
("2070-12-31", "2024-02-29", {"readonly": True, "default": "2024-02-29"}),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -935,22 +959,26 @@ class TestTime(BaseTest):
|
|||
}
|
||||
# fmt: off
|
||||
scenarios = [
|
||||
*all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
|
||||
*all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
|
||||
# Those passes since False|True are parsed as 0|1 then int|float are considered a timestamp in seconds but we don't take seconds into account so -> 00:00
|
||||
*all_as(False, True, 0, 1, 13.37, "0", "1", "13.37", output="00:00"),
|
||||
# 1337 seconds == 22 minutes
|
||||
*all_as(1337, "1337", output="00:22"),
|
||||
# Negative timestamp fails
|
||||
*all_fails(-1, "-1", error=OverflowError), # FIXME should handle that as a validation error
|
||||
# *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
|
||||
*all_fails("none", "_none", "False", "True", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
|
||||
*nones(None, "", output=""),
|
||||
# custom valid
|
||||
*unchanged("00:00", "08:00", "12:19", "20:59", "23:59"),
|
||||
("3:00", "3:00"), # FIXME should fail or output as `"03:00"`?
|
||||
*xfail(scenarios=[
|
||||
("3:00", "03:00"),
|
||||
("23:1", "23:01"),
|
||||
("22:35:05", "22:35"),
|
||||
("22:35:03.514", "22:35"),
|
||||
], reason="time as iso format could be valid"),
|
||||
# custom invalid
|
||||
("24:00", FAIL),
|
||||
("23:1", FAIL),
|
||||
("23:005", FAIL),
|
||||
# readonly
|
||||
("00:00", "08:00", {"readonly": True, "current_value": "08:00"}),
|
||||
("00:00", "08:00", {"readonly": True, "default": "08:00"}),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -973,72 +1001,75 @@ class TestEmail(BaseTest):
|
|||
|
||||
*nones(None, "", output=""),
|
||||
("\n Abc@example.tld ", "Abc@example.tld"),
|
||||
*xfail(scenarios=[("admin@ynh.local", "admin@ynh.local")], reason="Should this pass?"),
|
||||
# readonly
|
||||
("Abc@example.tld", "admin@ynh.local", {"readonly": True, "current_value": "admin@ynh.local"}),
|
||||
("Abc@example.tld", "admin@ynh.org", {"readonly": True, "default": "admin@ynh.org"}),
|
||||
|
||||
# Next examples are from https://github.com/JoshData/python-email-validator/blob/main/tests/test_syntax.py
|
||||
# valid email values
|
||||
("Abc@example.tld", "Abc@example.tld"),
|
||||
("Abc.123@test-example.com", "Abc.123@test-example.com"),
|
||||
("user+mailbox/department=shipping@example.tld", "user+mailbox/department=shipping@example.tld"),
|
||||
("伊昭傑@郵件.商務", "伊昭傑@郵件.商務"),
|
||||
("राम@मोहन.ईन्फो", "राम@मोहन.ईन्फो"),
|
||||
("юзер@екзампл.ком", "юзер@екзампл.ком"),
|
||||
("θσερ@εχαμπλε.ψομ", "θσερ@εχαμπλε.ψομ"),
|
||||
("葉士豪@臺網中心.tw", "葉士豪@臺網中心.tw"),
|
||||
("jeff@臺網中心.tw", "jeff@臺網中心.tw"),
|
||||
("葉士豪@臺網中心.台灣", "葉士豪@臺網中心.台灣"),
|
||||
("jeff葉@臺網中心.tw", "jeff葉@臺網中心.tw"),
|
||||
("ñoñó@example.tld", "ñoñó@example.tld"),
|
||||
("甲斐黒川日本@example.tld", "甲斐黒川日本@example.tld"),
|
||||
("чебурашкаящик-с-апельсинами.рф@example.tld", "чебурашкаящик-с-апельсинами.рф@example.tld"),
|
||||
("उदाहरण.परीक्ष@domain.with.idn.tld", "उदाहरण.परीक्ष@domain.with.idn.tld"),
|
||||
("ιωάννης@εεττ.gr", "ιωάννης@εεττ.gr"),
|
||||
*unchanged(
|
||||
"Abc@example.tld",
|
||||
"Abc.123@test-example.com",
|
||||
"user+mailbox/department=shipping@example.tld",
|
||||
"伊昭傑@郵件.商務",
|
||||
"राम@मोहन.ईन्फो",
|
||||
"юзер@екзампл.ком",
|
||||
"θσερ@εχαμπλε.ψομ",
|
||||
"葉士豪@臺網中心.tw",
|
||||
"jeff@臺網中心.tw",
|
||||
"葉士豪@臺網中心.台灣",
|
||||
"jeff葉@臺網中心.tw",
|
||||
"ñoñó@example.tld",
|
||||
"甲斐黒川日本@example.tld",
|
||||
"чебурашкаящик-с-апельсинами.рф@example.tld",
|
||||
"उदाहरण.परीक्ष@domain.with.idn.tld",
|
||||
"ιωάννης@εεττ.gr",
|
||||
),
|
||||
# invalid email (Hiding because our current regex is very permissive)
|
||||
# ("my@localhost", FAIL),
|
||||
# ("my@.leadingdot.com", FAIL),
|
||||
# ("my@.leadingfwdot.com", FAIL),
|
||||
# ("my@twodots..com", FAIL),
|
||||
# ("my@twofwdots...com", FAIL),
|
||||
# ("my@trailingdot.com.", FAIL),
|
||||
# ("my@trailingfwdot.com.", FAIL),
|
||||
# ("me@-leadingdash", FAIL),
|
||||
# ("me@-leadingdashfw", FAIL),
|
||||
# ("me@trailingdash-", FAIL),
|
||||
# ("me@trailingdashfw-", FAIL),
|
||||
# ("my@baddash.-.com", FAIL),
|
||||
# ("my@baddash.-a.com", FAIL),
|
||||
# ("my@baddash.b-.com", FAIL),
|
||||
# ("my@baddashfw.-.com", FAIL),
|
||||
# ("my@baddashfw.-a.com", FAIL),
|
||||
# ("my@baddashfw.b-.com", FAIL),
|
||||
# ("my@example.com\n", FAIL),
|
||||
# ("my@example\n.com", FAIL),
|
||||
# ("me@x!", FAIL),
|
||||
# ("me@x ", FAIL),
|
||||
# (".leadingdot@domain.com", FAIL),
|
||||
# ("twodots..here@domain.com", FAIL),
|
||||
# ("trailingdot.@domain.email", FAIL),
|
||||
# ("me@⒈wouldbeinvalid.com", FAIL),
|
||||
("@example.com", FAIL),
|
||||
# ("\nmy@example.com", FAIL),
|
||||
("m\ny@example.com", FAIL),
|
||||
("my\n@example.com", FAIL),
|
||||
# ("11111111112222222222333333333344444444445555555555666666666677777@example.com", FAIL),
|
||||
# ("111111111122222222223333333333444444444455555555556666666666777777@example.com", FAIL),
|
||||
# ("me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444444444455555555556.com", FAIL),
|
||||
# ("me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com", FAIL),
|
||||
# ("me@中1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com", FAIL),
|
||||
# ("my.long.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333333344444.info", FAIL),
|
||||
# ("my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333.info", FAIL),
|
||||
# ("my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info", FAIL),
|
||||
# ("my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444.info", FAIL),
|
||||
# ("my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info", FAIL),
|
||||
# ("me@bad-tld-1", FAIL),
|
||||
# ("me@bad.tld-2", FAIL),
|
||||
# ("me@xn--0.tld", FAIL),
|
||||
# ("me@yy--0.tld", FAIL),
|
||||
# ("me@yy--0.tld", FAIL),
|
||||
*all_fails(
|
||||
"my@localhost",
|
||||
"my@.leadingdot.com",
|
||||
"my@.leadingfwdot.com",
|
||||
"my@twodots..com",
|
||||
"my@twofwdots...com",
|
||||
"my@trailingdot.com.",
|
||||
"my@trailingfwdot.com.",
|
||||
"me@-leadingdash",
|
||||
"me@-leadingdashfw",
|
||||
"me@trailingdash-",
|
||||
"me@trailingdashfw-",
|
||||
"my@baddash.-.com",
|
||||
"my@baddash.-a.com",
|
||||
"my@baddash.b-.com",
|
||||
"my@baddashfw.-.com",
|
||||
"my@baddashfw.-a.com",
|
||||
"my@baddashfw.b-.com",
|
||||
"my@example\n.com",
|
||||
"me@x!",
|
||||
"me@x ",
|
||||
".leadingdot@domain.com",
|
||||
"twodots..here@domain.com",
|
||||
"trailingdot.@domain.email",
|
||||
"me@⒈wouldbeinvalid.com",
|
||||
"@example.com",
|
||||
"m\ny@example.com",
|
||||
"my\n@example.com",
|
||||
"11111111112222222222333333333344444444445555555555666666666677777@example.com",
|
||||
"111111111122222222223333333333444444444455555555556666666666777777@example.com",
|
||||
"me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444444444455555555556.com",
|
||||
"me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com",
|
||||
"me@中1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com",
|
||||
"my.long.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333333344444.info",
|
||||
"my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333.info",
|
||||
"my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info",
|
||||
"my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444.info",
|
||||
"my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info",
|
||||
"me@bad-tld-1",
|
||||
"me@bad.tld-2",
|
||||
"me@xn--0.tld",
|
||||
"me@yy--0.tld",
|
||||
"me@yy--0.tld",
|
||||
)
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -1087,7 +1118,7 @@ class TestWebPath(BaseTest):
|
|||
("https://example.com/folder", "/https://example.com/folder")
|
||||
], reason="Should fail or scheme+domain removed"),
|
||||
# readonly
|
||||
("/overwrite", "/value", {"readonly": True, "current_value": "/value"}),
|
||||
("/overwrite", "/value", {"readonly": True, "default": "/value"}),
|
||||
# FIXME should path have forbidden_chars?
|
||||
]
|
||||
# fmt: on
|
||||
|
@ -1111,21 +1142,17 @@ class TestUrl(BaseTest):
|
|||
|
||||
*nones(None, "", output=""),
|
||||
("http://some.org/folder/file.txt", "http://some.org/folder/file.txt"),
|
||||
(' https://www.example.com \n', 'https://www.example.com'),
|
||||
# readonly
|
||||
("https://overwrite.org", "https://example.org", {"readonly": True, "current_value": "https://example.org"}),
|
||||
("https://overwrite.org", "https://example.org", {"readonly": True, "default": "https://example.org"}),
|
||||
# rest is taken from https://github.com/pydantic/pydantic/blob/main/tests/test_networks.py
|
||||
# valid
|
||||
*unchanged(
|
||||
# Those are valid but not sure how they will output with pydantic
|
||||
'http://example.org',
|
||||
'http://test',
|
||||
'http://localhost',
|
||||
'https://example.org/whatever/next/',
|
||||
'https://example.org',
|
||||
'http://localhost',
|
||||
'http://localhost/',
|
||||
'http://localhost:8000',
|
||||
'http://localhost:8000/',
|
||||
|
||||
'https://foo_bar.example.com/',
|
||||
'http://example.co.jp',
|
||||
'http://www.example.com/a%C2%B1b',
|
||||
|
@ -1149,29 +1176,31 @@ class TestUrl(BaseTest):
|
|||
'http://twitter.com/@handle/',
|
||||
'http://11.11.11.11.example.com/action',
|
||||
'http://abc.11.11.11.11.example.com/action',
|
||||
'http://example#',
|
||||
'http://example/#',
|
||||
'http://example/#fragment',
|
||||
'http://example/?#',
|
||||
'http://example.org/path#',
|
||||
'http://example.org/path#fragment',
|
||||
'http://example.org/path?query#',
|
||||
'http://example.org/path?query#fragment',
|
||||
'https://foo_bar.example.com/',
|
||||
'https://exam_ple.com/',
|
||||
'HTTP://EXAMPLE.ORG',
|
||||
'https://example.org',
|
||||
'https://example.org?a=1&b=2',
|
||||
'https://example.org#a=3;b=3',
|
||||
'https://example.xn--p1ai',
|
||||
'https://example.xn--vermgensberatung-pwb',
|
||||
'https://example.xn--zfr164b',
|
||||
),
|
||||
# Pydantic default parsing add a final `/`
|
||||
('https://foo_bar.example.com/', 'https://foo_bar.example.com/'),
|
||||
('https://exam_ple.com/', 'https://exam_ple.com/'),
|
||||
*xfail(scenarios=[
|
||||
(' https://www.example.com \n', 'https://www.example.com/'),
|
||||
('HTTP://EXAMPLE.ORG', 'http://example.org/'),
|
||||
('https://example.org', 'https://example.org/'),
|
||||
('https://example.org?a=1&b=2', 'https://example.org/?a=1&b=2'),
|
||||
('https://example.org#a=3;b=3', 'https://example.org/#a=3;b=3'),
|
||||
('https://example.xn--p1ai', 'https://example.xn--p1ai/'),
|
||||
('https://example.xn--vermgensberatung-pwb', 'https://example.xn--vermgensberatung-pwb/'),
|
||||
('https://example.xn--zfr164b', 'https://example.xn--zfr164b/'),
|
||||
], reason="pydantic default behavior would append a final `/`"),
|
||||
|
||||
('http://test', 'http://test'),
|
||||
('http://localhost', 'http://localhost'),
|
||||
('http://localhost/', 'http://localhost/'),
|
||||
('http://localhost:8000', 'http://localhost:8000'),
|
||||
('http://localhost:8000/', 'http://localhost:8000/'),
|
||||
('http://example#', 'http://example#'),
|
||||
('http://example/#', 'http://example/#'),
|
||||
('http://example/#fragment', 'http://example/#fragment'),
|
||||
('http://example/?#', 'http://example/?#'),
|
||||
], reason="Should this be valid?"),
|
||||
# invalid
|
||||
*all_fails(
|
||||
'ftp://example.com/',
|
||||
|
@ -1182,15 +1211,13 @@ class TestUrl(BaseTest):
|
|||
"/",
|
||||
"+http://example.com/",
|
||||
"ht*tp://example.com/",
|
||||
"http:///",
|
||||
"http://??",
|
||||
"https://example.org more",
|
||||
"http://2001:db8::ff00:42:8329",
|
||||
"http://[192.168.1.1]:8329",
|
||||
"http://example.com:99999",
|
||||
),
|
||||
*xpass(scenarios=[
|
||||
("http:///", "http:///"),
|
||||
("http://??", "http://??"),
|
||||
("https://example.org more", "https://example.org more"),
|
||||
("http://2001:db8::ff00:42:8329", "http://2001:db8::ff00:42:8329"),
|
||||
("http://[192.168.1.1]:8329", "http://[192.168.1.1]:8329"),
|
||||
("http://example.com:99999", "http://example.com:99999"),
|
||||
], reason="Should fail"),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -1361,7 +1388,6 @@ class TestSelect(BaseTest):
|
|||
# [-1, 0, 1]
|
||||
"raw_options": [
|
||||
{"choices": [-1, 0, 1, 10]},
|
||||
{"choices": {-1: "verbose -one", 0: "verbose zero", 1: "verbose one", 10: "verbose ten"}},
|
||||
],
|
||||
"scenarios": [
|
||||
*nones(None, "", output=""),
|
||||
|
@ -1375,6 +1401,18 @@ class TestSelect(BaseTest):
|
|||
*all_fails("100", 100),
|
||||
]
|
||||
},
|
||||
{
|
||||
"raw_options": [
|
||||
{"choices": {-1: "verbose -one", 0: "verbose zero", 1: "verbose one", 10: "verbose ten"}},
|
||||
{"choices": {"-1": "verbose -one", "0": "verbose zero", "1": "verbose one", "10": "verbose ten"}},
|
||||
],
|
||||
"scenarios": [
|
||||
*nones(None, "", output=""),
|
||||
*all_fails(-1, 0, 1, 10), # Should pass? converted to str?
|
||||
*unchanged("-1", "0", "1", "10"),
|
||||
*all_fails("100", 100),
|
||||
]
|
||||
},
|
||||
# [True, False, None]
|
||||
*unchanged(True, False, raw_option={"choices": [True, False, None]}), # FIXME we should probably forbid None in choices
|
||||
(None, FAIL, {"choices": [True, False, None]}),
|
||||
|
@ -1402,7 +1440,7 @@ class TestSelect(BaseTest):
|
|||
]
|
||||
},
|
||||
# readonly
|
||||
("one", "two", {"readonly": True, "choices": ["one", "two"], "current_value": "two"}),
|
||||
("one", "two", {"readonly": True, "choices": ["one", "two"], "default": "two"}),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -1411,7 +1449,7 @@ class TestSelect(BaseTest):
|
|||
# │ TAGS │
|
||||
# ╰───────────────────────────────────────────────────────╯
|
||||
|
||||
|
||||
# [], ["one"], {}
|
||||
class TestTags(BaseTest):
|
||||
raw_option = {"type": "tags", "id": "tags_id"}
|
||||
prefill = {
|
||||
|
@ -1420,12 +1458,7 @@ class TestTags(BaseTest):
|
|||
}
|
||||
# fmt: off
|
||||
scenarios = [
|
||||
*nones(None, [], "", output=""),
|
||||
# FIXME `","` could be considered a none value which kinda already is since it fail when required
|
||||
(",", FAIL),
|
||||
*xpass(scenarios=[
|
||||
(",", ",", {"optional": True})
|
||||
], reason="Should output as `''`? ie: None"),
|
||||
*nones(None, [], "", ",", output=""),
|
||||
{
|
||||
"raw_options": [
|
||||
{},
|
||||
|
@ -1445,12 +1478,12 @@ class TestTags(BaseTest):
|
|||
# basic types (not in a list) should fail
|
||||
*all_fails(True, False, -1, 0, 1, 1337, 13.37, {}),
|
||||
# Mixed choices should fail
|
||||
([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], FAIL, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
|
||||
("False,True,-1,0,1,1337,13.37,[],['one'],{}", FAIL, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
|
||||
*all_fails(*([t] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
|
||||
*all_fails(*([str(t)] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
|
||||
([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], YunohostError, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
|
||||
("False,True,-1,0,1,1337,13.37,[],['one'],{}", YunohostError, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
|
||||
*all_fails(*([t] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}, error=YunohostError),
|
||||
*all_fails(*([str(t)] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}, error=YunohostError),
|
||||
# readonly
|
||||
("one", "one,two", {"readonly": True, "choices": ["one", "two"], "current_value": "one,two"}),
|
||||
("one", "one,two", {"readonly": True, "choices": ["one", "two"], "default": "one,two"}),
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
|
@ -1566,8 +1599,7 @@ class TestApp(BaseTest):
|
|||
],
|
||||
"scenarios": [
|
||||
# FIXME there are currently 3 different nones (`None`, `""` and `_none`), choose one?
|
||||
*nones(None, output=None), # FIXME Should return chosen none?
|
||||
*nones("", output=""), # FIXME Should return chosen none?
|
||||
*nones(None, "", output=""), # FIXME Should return chosen none?
|
||||
*xpass(scenarios=[
|
||||
("_none", "_none"),
|
||||
("_none", "_none", {"default": "_none"}),
|
||||
|
@ -1590,7 +1622,7 @@ class TestApp(BaseTest):
|
|||
(installed_webapp["id"], installed_webapp["id"], {"filter": "is_webapp"}),
|
||||
(installed_webapp["id"], FAIL, {"filter": "is_webapp == false"}),
|
||||
(installed_webapp["id"], FAIL, {"filter": "id != 'my_webapp'"}),
|
||||
(None, None, {"filter": "id == 'fake_app'", "optional": True}),
|
||||
(None, "", {"filter": "id == 'fake_app'", "optional": True}),
|
||||
]
|
||||
},
|
||||
{
|
||||
|
@ -1796,9 +1828,7 @@ class TestGroup(BaseTest):
|
|||
"scenarios": [
|
||||
("custom_group", "custom_group"),
|
||||
*all_as("", None, output="visitors", raw_option={"default": "visitors"}),
|
||||
*xpass(scenarios=[
|
||||
("", "custom_group", {"default": "custom_group"}),
|
||||
], reason="Should throw 'default must be in (None, 'all_users', 'visitors', 'admins')"),
|
||||
("", YunohostError, {"default": "custom_group"}), # Not allowed to set a default which is not a default group
|
||||
# readonly
|
||||
("admins", YunohostError, {"readonly": True}), # readonly is forbidden
|
||||
]
|
||||
|
@ -1817,13 +1847,6 @@ class TestGroup(BaseTest):
|
|||
"prefill": "admins",
|
||||
}
|
||||
)
|
||||
# FIXME This should fail, not allowed to set a default which is not a default group
|
||||
super().test_options_prompted_with_ask_help(
|
||||
prefill_data={
|
||||
"raw_option": {"default": "custom_group"},
|
||||
"prefill": "custom_group",
|
||||
}
|
||||
)
|
||||
|
||||
def test_scenarios(self, intake, expected_output, raw_option, data):
|
||||
with patch_groups(**data):
|
||||
|
@ -1880,12 +1903,12 @@ def test_options_query_string():
|
|||
"string_id": "string",
|
||||
"text_id": "text\ntext",
|
||||
"password_id": "sUpRSCRT",
|
||||
"color_id": "#ffff00",
|
||||
"color_id": "#ff0",
|
||||
"number_id": 10,
|
||||
"boolean_id": 1,
|
||||
"date_id": "2030-03-06",
|
||||
"time_id": "20:55",
|
||||
"email_id": "coucou@ynh.local",
|
||||
"email_id": "coucou@ynh.org",
|
||||
"path_id": "/ynh-dev",
|
||||
"url_id": "https://yunohost.org",
|
||||
"file_id": file_content1,
|
||||
|
@ -1908,7 +1931,7 @@ def test_options_query_string():
|
|||
"&boolean_id=y"
|
||||
"&date_id=2030-03-06"
|
||||
"&time_id=20:55"
|
||||
"&email_id=coucou@ynh.local"
|
||||
"&email_id=coucou@ynh.org"
|
||||
"&path_id=ynh-dev/"
|
||||
"&url_id=https://yunohost.org"
|
||||
f"&file_id={file_repr}"
|
||||
|
@ -1925,9 +1948,7 @@ def test_options_query_string():
|
|||
"&fake_id=fake_value"
|
||||
)
|
||||
|
||||
def _assert_correct_values(options, raw_options):
|
||||
form = {option.id: option.value for option in options}
|
||||
|
||||
def _assert_correct_values(options, form, raw_options):
|
||||
for k, v in results.items():
|
||||
if k == "file_id":
|
||||
assert os.path.exists(form["file_id"]) and os.path.isfile(
|
||||
|
@ -1943,24 +1964,24 @@ def test_options_query_string():
|
|||
|
||||
with patch_interface("api"), patch_file_api(file_content1) as b64content:
|
||||
with patch_query_string(b64content.decode("utf-8")) as query_string:
|
||||
options = ask_questions_and_parse_answers(raw_options, query_string)
|
||||
_assert_correct_values(options, raw_options)
|
||||
options, form = ask_questions_and_parse_answers(raw_options, query_string)
|
||||
_assert_correct_values(options, form, raw_options)
|
||||
|
||||
with patch_interface("cli"), patch_file_cli(file_content1) as filepath:
|
||||
with patch_query_string(filepath) as query_string:
|
||||
options = ask_questions_and_parse_answers(raw_options, query_string)
|
||||
_assert_correct_values(options, raw_options)
|
||||
options, form = ask_questions_and_parse_answers(raw_options, query_string)
|
||||
_assert_correct_values(options, form, raw_options)
|
||||
|
||||
|
||||
def test_question_string_default_type():
|
||||
questions = {"some_string": {}}
|
||||
answers = {"some_string": "some_value"}
|
||||
|
||||
out = ask_questions_and_parse_answers(questions, answers)[0]
|
||||
|
||||
assert out.id == "some_string"
|
||||
assert out.type == "string"
|
||||
assert out.value == "some_value"
|
||||
options, form = ask_questions_and_parse_answers(questions, answers)
|
||||
option = options[0]
|
||||
assert option.id == "some_string"
|
||||
assert option.type == "string"
|
||||
assert form[option.id] == "some_value"
|
||||
|
||||
|
||||
def test_option_default_type_with_choices_is_select():
|
||||
|
@ -1972,10 +1993,10 @@ def test_option_default_type_with_choices_is_select():
|
|||
}
|
||||
answers = {"some_choices": "a", "some_legacy": "a"}
|
||||
|
||||
options = ask_questions_and_parse_answers(questions, answers)
|
||||
options, form = ask_questions_and_parse_answers(questions, answers)
|
||||
for option in options:
|
||||
assert option.type == "select"
|
||||
assert option.value == "a"
|
||||
assert form[option.id] == "a"
|
||||
|
||||
|
||||
@pytest.mark.skip # we should do something with this example
|
||||
|
|
File diff suppressed because it is too large
Load diff
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