mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
form: add dynamic annotation getters
This commit is contained in:
parent
89ae5e654d
commit
3943774811
1 changed files with 140 additions and 21 deletions
|
@ -27,6 +27,8 @@ import urllib.parse
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from typing import (
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
cast,
|
||||||
Annotated,
|
Annotated,
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
|
@ -35,7 +37,6 @@ from typing import (
|
||||||
Mapping,
|
Mapping,
|
||||||
Type,
|
Type,
|
||||||
Union,
|
Union,
|
||||||
cast,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
|
@ -49,6 +50,7 @@ from pydantic import (
|
||||||
from pydantic.color import Color
|
from pydantic.color import Color
|
||||||
from pydantic.fields import Field
|
from pydantic.fields import Field
|
||||||
from pydantic.networks import EmailStr, HttpUrl
|
from pydantic.networks import EmailStr, HttpUrl
|
||||||
|
from pydantic.types import constr
|
||||||
|
|
||||||
from moulinette import Moulinette, m18n
|
from moulinette import Moulinette, m18n
|
||||||
from moulinette.interfaces.cli import colorize
|
from moulinette.interfaces.cli import colorize
|
||||||
|
@ -57,6 +59,9 @@ from yunohost.log import OperationLogger
|
||||||
from yunohost.utils.error import YunohostError, YunohostValidationError
|
from yunohost.utils.error import YunohostError, YunohostValidationError
|
||||||
from yunohost.utils.i18n import _value_for_locale
|
from yunohost.utils.i18n import _value_for_locale
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pydantic.fields import FieldInfo
|
||||||
|
|
||||||
logger = getLogger("yunohost.form")
|
logger = getLogger("yunohost.form")
|
||||||
|
|
||||||
|
|
||||||
|
@ -391,6 +396,7 @@ class BaseInputOption(BaseOption):
|
||||||
redact: bool = False
|
redact: bool = False
|
||||||
optional: bool = False # FIXME keep required as default?
|
optional: bool = False # FIXME keep required as default?
|
||||||
default: Any = None
|
default: Any = None
|
||||||
|
_annotation = Any
|
||||||
|
|
||||||
@validator("default", pre=True)
|
@validator("default", pre=True)
|
||||||
def check_empty_default(value: Any) -> Any:
|
def check_empty_default(value: Any) -> Any:
|
||||||
|
@ -408,6 +414,57 @@ class BaseInputOption(BaseOption):
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _dynamic_annotation(self) -> Any:
|
||||||
|
"""
|
||||||
|
Returns the expected type of an Option's value.
|
||||||
|
This may be dynamic based on constraints.
|
||||||
|
"""
|
||||||
|
return self._annotation
|
||||||
|
|
||||||
|
def _get_field_attrs(self) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Returns attributes to build a `pydantic.Field`.
|
||||||
|
This may contains non `Field` attrs that will end up in `Field.extra`.
|
||||||
|
Those extra can be used as constraints in custom validators and ends up
|
||||||
|
in the JSON Schema.
|
||||||
|
"""
|
||||||
|
# TODO
|
||||||
|
# - help
|
||||||
|
# - placeholder
|
||||||
|
attrs: dict[str, Any] = {
|
||||||
|
"redact": self.redact, # extra
|
||||||
|
"none_as_empty_str": self._none_as_empty_str,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.readonly:
|
||||||
|
attrs["allow_mutation"] = False
|
||||||
|
|
||||||
|
if self.example:
|
||||||
|
attrs["examples"] = [self.example]
|
||||||
|
|
||||||
|
if self.default is not None:
|
||||||
|
attrs["default"] = self.default
|
||||||
|
else:
|
||||||
|
attrs["default"] = ... if not self.optional else None
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def _as_dynamic_model_field(self) -> tuple[Any, "FieldInfo"]:
|
||||||
|
"""
|
||||||
|
Return a tuple of a type and a Field instance to be injected in a
|
||||||
|
custom form declaration.
|
||||||
|
"""
|
||||||
|
attrs = self._get_field_attrs()
|
||||||
|
anno = (
|
||||||
|
self._dynamic_annotation
|
||||||
|
if not self.optional
|
||||||
|
else Union[self._dynamic_annotation, None]
|
||||||
|
)
|
||||||
|
field = Field(default=attrs.pop("default", None), **attrs)
|
||||||
|
|
||||||
|
return (anno, field)
|
||||||
|
|
||||||
def _get_prompt_message(self, value: Any) -> str:
|
def _get_prompt_message(self, value: Any) -> str:
|
||||||
message = super()._get_prompt_message(value)
|
message = super()._get_prompt_message(value)
|
||||||
|
|
||||||
|
@ -460,6 +517,22 @@ class BaseInputOption(BaseOption):
|
||||||
class BaseStringOption(BaseInputOption):
|
class BaseStringOption(BaseInputOption):
|
||||||
default: Union[str, None]
|
default: Union[str, None]
|
||||||
pattern: Union[Pattern, None] = None
|
pattern: Union[Pattern, None] = None
|
||||||
|
_annotation = str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _dynamic_annotation(self) -> Type[str]:
|
||||||
|
if self.pattern:
|
||||||
|
return constr(regex=self.pattern.regexp)
|
||||||
|
|
||||||
|
return self._annotation
|
||||||
|
|
||||||
|
def _get_field_attrs(self) -> dict[str, Any]:
|
||||||
|
attrs = super()._get_field_attrs()
|
||||||
|
|
||||||
|
if self.pattern:
|
||||||
|
attrs["regex_error"] = self.pattern.error # extra
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class StringOption(BaseStringOption):
|
class StringOption(BaseStringOption):
|
||||||
|
@ -478,8 +551,16 @@ class PasswordOption(BaseInputOption):
|
||||||
example: Literal[None] = None
|
example: Literal[None] = None
|
||||||
default: Literal[None] = None
|
default: Literal[None] = None
|
||||||
redact: Literal[True] = True
|
redact: Literal[True] = True
|
||||||
|
_annotation = str
|
||||||
_forbidden_chars: str = FORBIDDEN_PASSWORD_CHARS
|
_forbidden_chars: str = FORBIDDEN_PASSWORD_CHARS
|
||||||
|
|
||||||
|
def _get_field_attrs(self) -> dict[str, Any]:
|
||||||
|
attrs = super()._get_field_attrs()
|
||||||
|
|
||||||
|
attrs["forbidden_chars"] = self._forbidden_chars # extra
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
def _value_pre_validator(self):
|
||||||
super()._value_pre_validator()
|
super()._value_pre_validator()
|
||||||
|
|
||||||
|
@ -498,10 +579,7 @@ class PasswordOption(BaseInputOption):
|
||||||
class ColorOption(BaseInputOption):
|
class ColorOption(BaseInputOption):
|
||||||
type: Literal[OptionType.color] = OptionType.color
|
type: Literal[OptionType.color] = OptionType.color
|
||||||
default: Union[str, None]
|
default: Union[str, None]
|
||||||
# pattern = {
|
_annotation = Color
|
||||||
# "regexp": r"^#[ABCDEFabcdef\d]{3,6}$",
|
|
||||||
# "error": "config_validate_color", # i18n: config_validate_color
|
|
||||||
# }
|
|
||||||
|
|
||||||
|
|
||||||
# ─ NUMERIC ───────────────────────────────────────────────
|
# ─ NUMERIC ───────────────────────────────────────────────
|
||||||
|
@ -514,6 +592,7 @@ class NumberOption(BaseInputOption):
|
||||||
min: Union[int, None] = None
|
min: Union[int, None] = None
|
||||||
max: Union[int, None] = None
|
max: Union[int, None] = None
|
||||||
step: Union[int, None] = None
|
step: Union[int, None] = None
|
||||||
|
_annotation = int
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize(value, option={}):
|
def normalize(value, option={}):
|
||||||
|
@ -536,6 +615,14 @@ class NumberOption(BaseInputOption):
|
||||||
error=m18n.n("invalid_number"),
|
error=m18n.n("invalid_number"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _get_field_attrs(self) -> dict[str, Any]:
|
||||||
|
attrs = super()._get_field_attrs()
|
||||||
|
attrs["ge"] = self.min
|
||||||
|
attrs["le"] = self.max
|
||||||
|
attrs["step"] = self.step # extra
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
def _value_pre_validator(self):
|
||||||
super()._value_pre_validator()
|
super()._value_pre_validator()
|
||||||
if self.value in [None, ""]:
|
if self.value in [None, ""]:
|
||||||
|
@ -564,6 +651,7 @@ class BooleanOption(BaseInputOption):
|
||||||
yes: Any = 1
|
yes: Any = 1
|
||||||
no: Any = 0
|
no: Any = 0
|
||||||
default: Union[bool, int, str, None] = 0
|
default: Union[bool, int, str, None] = 0
|
||||||
|
_annotation = Union[bool, int, str]
|
||||||
_yes_answers: set[str] = {"1", "yes", "y", "true", "t", "on"}
|
_yes_answers: set[str] = {"1", "yes", "y", "true", "t", "on"}
|
||||||
_no_answers: set[str] = {"0", "no", "n", "false", "f", "off"}
|
_no_answers: set[str] = {"0", "no", "n", "false", "f", "off"}
|
||||||
|
|
||||||
|
@ -633,6 +721,14 @@ class BooleanOption(BaseInputOption):
|
||||||
def get(self, key, default=None):
|
def get(self, key, default=None):
|
||||||
return getattr(self, key, default)
|
return getattr(self, key, default)
|
||||||
|
|
||||||
|
def _get_field_attrs(self) -> dict[str, Any]:
|
||||||
|
attrs = super()._get_field_attrs()
|
||||||
|
attrs["parse"] = { # extra
|
||||||
|
True: self.yes,
|
||||||
|
False: self.no,
|
||||||
|
}
|
||||||
|
return attrs
|
||||||
|
|
||||||
def _get_prompt_message(self, value: Union[bool, None]) -> str:
|
def _get_prompt_message(self, value: Union[bool, None]) -> str:
|
||||||
message = super()._get_prompt_message(value)
|
message = super()._get_prompt_message(value)
|
||||||
|
|
||||||
|
@ -648,10 +744,7 @@ class BooleanOption(BaseInputOption):
|
||||||
class DateOption(BaseInputOption):
|
class DateOption(BaseInputOption):
|
||||||
type: Literal[OptionType.date] = OptionType.date
|
type: Literal[OptionType.date] = OptionType.date
|
||||||
default: Union[str, None]
|
default: Union[str, None]
|
||||||
# pattern = {
|
_annotation = datetime.date
|
||||||
# "regexp": r"^\d{4}-\d\d-\d\d$",
|
|
||||||
# "error": "config_validate_date", # i18n: config_validate_date
|
|
||||||
# }
|
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
def _value_pre_validator(self):
|
||||||
super()._value_pre_validator()
|
super()._value_pre_validator()
|
||||||
|
@ -666,10 +759,7 @@ class DateOption(BaseInputOption):
|
||||||
class TimeOption(BaseInputOption):
|
class TimeOption(BaseInputOption):
|
||||||
type: Literal[OptionType.time] = OptionType.time
|
type: Literal[OptionType.time] = OptionType.time
|
||||||
default: Union[str, int, None]
|
default: Union[str, int, None]
|
||||||
# pattern = {
|
_annotation = datetime.time
|
||||||
# "regexp": r"^(?:\d|[01]\d|2[0-3]):[0-5]\d$",
|
|
||||||
# "error": "config_validate_time", # i18n: config_validate_time
|
|
||||||
# }
|
|
||||||
|
|
||||||
|
|
||||||
# ─ LOCATIONS ─────────────────────────────────────────────
|
# ─ LOCATIONS ─────────────────────────────────────────────
|
||||||
|
@ -678,15 +768,13 @@ class TimeOption(BaseInputOption):
|
||||||
class EmailOption(BaseInputOption):
|
class EmailOption(BaseInputOption):
|
||||||
type: Literal[OptionType.email] = OptionType.email
|
type: Literal[OptionType.email] = OptionType.email
|
||||||
default: Union[EmailStr, None]
|
default: Union[EmailStr, None]
|
||||||
# pattern = {
|
_annotation = EmailStr
|
||||||
# "regexp": r"^.+@.+",
|
|
||||||
# "error": "config_validate_email", # i18n: config_validate_email
|
|
||||||
# }
|
|
||||||
|
|
||||||
|
|
||||||
class WebPathOption(BaseInputOption):
|
class WebPathOption(BaseInputOption):
|
||||||
type: Literal[OptionType.path] = OptionType.path
|
type: Literal[OptionType.path] = OptionType.path
|
||||||
default: Union[str, None]
|
default: Union[str, None]
|
||||||
|
_annotation = str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def normalize(value, option={}):
|
def normalize(value, option={}):
|
||||||
|
@ -718,10 +806,7 @@ class WebPathOption(BaseInputOption):
|
||||||
class URLOption(BaseStringOption):
|
class URLOption(BaseStringOption):
|
||||||
type: Literal[OptionType.url] = OptionType.url
|
type: Literal[OptionType.url] = OptionType.url
|
||||||
default: Union[str, None]
|
default: Union[str, None]
|
||||||
# pattern = {
|
_annotation = HttpUrl
|
||||||
# "regexp": r"^https?://.*$",
|
|
||||||
# "error": "config_validate_url", # i18n: config_validate_url
|
|
||||||
# }
|
|
||||||
|
|
||||||
|
|
||||||
# ─ FILE ──────────────────────────────────────────────────
|
# ─ FILE ──────────────────────────────────────────────────
|
||||||
|
@ -733,6 +818,7 @@ class FileOption(BaseInputOption):
|
||||||
# `bytes` for API (a base64 encoded file actually)
|
# `bytes` for API (a base64 encoded file actually)
|
||||||
accept: Union[str, None] = "" # currently only used by the web-admin
|
accept: Union[str, None] = "" # currently only used by the web-admin
|
||||||
default: Union[str, None]
|
default: Union[str, None]
|
||||||
|
_annotation = str # TODO could be Path at some point
|
||||||
_upload_dirs: set[str] = set()
|
_upload_dirs: set[str] = set()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -809,6 +895,17 @@ class BaseChoicesOption(BaseInputOption):
|
||||||
# FIXME probably forbid choices to be None?
|
# FIXME probably forbid choices to be None?
|
||||||
choices: Union[dict[str, Any], list[Any], None]
|
choices: Union[dict[str, Any], list[Any], None]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _dynamic_annotation(self) -> Union[object, Type[str]]:
|
||||||
|
if self.choices is not None:
|
||||||
|
choices = (
|
||||||
|
self.choices if isinstance(self.choices, list) else self.choices.keys()
|
||||||
|
)
|
||||||
|
# FIXME in case of dict, try to parse keys with `item_type` (at least number)
|
||||||
|
return Literal[tuple(choices)]
|
||||||
|
|
||||||
|
return self._annotation
|
||||||
|
|
||||||
def _get_prompt_message(self, value: Any) -> str:
|
def _get_prompt_message(self, value: Any) -> str:
|
||||||
message = super()._get_prompt_message(value)
|
message = super()._get_prompt_message(value)
|
||||||
|
|
||||||
|
@ -858,6 +955,7 @@ class SelectOption(BaseChoicesOption):
|
||||||
type: Literal[OptionType.select] = OptionType.select
|
type: Literal[OptionType.select] = OptionType.select
|
||||||
choices: Union[dict[str, Any], list[Any]]
|
choices: Union[dict[str, Any], list[Any]]
|
||||||
default: Union[str, None]
|
default: Union[str, None]
|
||||||
|
_annotation = str
|
||||||
|
|
||||||
|
|
||||||
class TagsOption(BaseChoicesOption):
|
class TagsOption(BaseChoicesOption):
|
||||||
|
@ -865,6 +963,7 @@ class TagsOption(BaseChoicesOption):
|
||||||
choices: Union[list[str], None] = None
|
choices: Union[list[str], None] = None
|
||||||
pattern: Union[Pattern, None] = None
|
pattern: Union[Pattern, None] = None
|
||||||
default: Union[str, list[str], None]
|
default: Union[str, list[str], None]
|
||||||
|
_annotation = str
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def humanize(value, option={}):
|
def humanize(value, option={}):
|
||||||
|
@ -880,6 +979,26 @@ class TagsOption(BaseChoicesOption):
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _dynamic_annotation(self):
|
||||||
|
# TODO use Literal when serialization is seperated from validation
|
||||||
|
# if self.choices is not None:
|
||||||
|
# return Literal[tuple(self.choices)]
|
||||||
|
|
||||||
|
# Repeat pattern stuff since we can't call the bare class `_dynamic_annotation` prop without instantiating it
|
||||||
|
if self.pattern:
|
||||||
|
return constr(regex=self.pattern.regexp)
|
||||||
|
|
||||||
|
return self._annotation
|
||||||
|
|
||||||
|
def _get_field_attrs(self) -> dict[str, Any]:
|
||||||
|
attrs = super()._get_field_attrs()
|
||||||
|
|
||||||
|
if self.choices:
|
||||||
|
attrs["choices"] = self.choices # extra
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
def _value_pre_validator(self):
|
def _value_pre_validator(self):
|
||||||
values = self.value
|
values = self.value
|
||||||
if isinstance(values, str):
|
if isinstance(values, str):
|
||||||
|
|
Loading…
Add table
Reference in a new issue