form: add dynamic annotation getters

This commit is contained in:
axolotle 2023-04-17 15:23:05 +02:00
parent 89ae5e654d
commit 3943774811

View file

@ -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):