Skip to content

Commit

Permalink
vue/validate: rewrite validate mechanics of all formspecs
Browse files Browse the repository at this point in the history
Change-Id: I1ed1bfeed42d7e29333c7a2f7df70318a7da8bac
  • Loading branch information
schnetzzz committed Jan 8, 2025
1 parent 7adcf95 commit 33b1ab8
Show file tree
Hide file tree
Showing 37 changed files with 594 additions and 657 deletions.
46 changes: 34 additions & 12 deletions cmk/gui/form_specs/vue/visitors/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,32 @@
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
# conditions defined in the file COPYING, which is part of this source code package.
import abc
from typing import Any, final, Generic, TypeVar
from typing import Any, Callable, final, Generic, Sequence, TypeVar

from cmk.ccc.exceptions import MKGeneralException

from cmk.gui.form_specs.vue.visitors._type_defs import (
DataForDisk,
DataOrigin,
FrontendModel,
InvalidValue,
ParsedValueModel,
VisitorOptions,
)
from cmk.gui.form_specs.vue.visitors._type_defs import DefaultValue as FormSpecDefaultValue
from cmk.gui.form_specs.vue.visitors._utils import (
compute_validation_errors,
compute_validators,
create_validation_error,
)

from cmk.rulesets.v1.form_specs import FormSpec
from cmk.rulesets.v1.form_specs._base import ModelT
from cmk.shared_typing import vue_formspec_components as shared_type_defs

FormSpecModel = TypeVar("FormSpecModel", bound=FormSpec[Any])


class FormSpecVisitor(abc.ABC, Generic[FormSpecModel, ModelT]):
class FormSpecVisitor(abc.ABC, Generic[FormSpecModel, ParsedValueModel, FrontendModel]):
@final
def __init__(self, form_spec: FormSpecModel, options: VisitorOptions) -> None:
self.form_spec = form_spec
Expand All @@ -36,13 +42,26 @@ def to_vue(self, raw_value: object) -> tuple[shared_type_defs.FormSpec, object]:
@final
def validate(self, raw_value: object) -> list[shared_type_defs.ValidationMessage]:
parsed_value = self._parse_value(self._migrate_disk_value(raw_value))
return self._validate(raw_value, parsed_value)
# Stage 1: Check if the value is invalid
if isinstance(parsed_value, InvalidValue):
return create_validation_error(parsed_value.fallback_value, parsed_value.reason)

# Stage 2: Check if the value of the nested elements report problems
if nested_validations := self._validate(raw_value, parsed_value):
# NOTE: During the migration phase, the Stage1 errors from
# non-migrated visitors may appear here -> OK
return nested_validations

# Stage 3: Execute validators of the element itself
return compute_validation_errors(self._validators(), self._to_disk(raw_value, parsed_value))

@final
def to_disk(self, raw_value: object) -> DataForDisk:
parsed_value = self._parse_value(self._migrate_disk_value(raw_value))
if isinstance(parsed_value, InvalidValue):
raise MKGeneralException("Unable to serialize empty value")
raise MKGeneralException(
"Unable to serialize invalid value. Reason: %s" % parsed_value.reason
)
return self._to_disk(raw_value, parsed_value)

def _migrate_disk_value(self, value: object) -> object:
Expand All @@ -55,24 +74,27 @@ def _migrate_disk_value(self, value: object) -> object:
return value

@abc.abstractmethod
def _parse_value(self, raw_value: object) -> ModelT | InvalidValue:
def _parse_value(self, raw_value: object) -> ParsedValueModel | InvalidValue[FrontendModel]:
"""Handle the raw value from the form and return a parsed value.
E.g., replaces DefaultValue sentinel with the actual default value
or returns EmptyValue if the raw value is of invalid data type."""

@abc.abstractmethod
def _to_vue(
self, raw_value: object, parsed_value: ModelT | InvalidValue
) -> tuple[shared_type_defs.FormSpec, object]:
self, raw_value: object, parsed_value: ParsedValueModel | InvalidValue[FrontendModel]
) -> tuple[shared_type_defs.FormSpec, FrontendModel]:
"""Returns frontend representation of the FormSpec schema and its data value."""

@abc.abstractmethod
def _validators(self) -> Sequence[Callable[[DataForDisk], object]]:
return compute_validators(self.form_spec)

def _validate(
self, raw_value: object, parsed_value: ModelT | InvalidValue
self, raw_value: object, parsed_value: ParsedValueModel
) -> list[shared_type_defs.ValidationMessage]:
"""Validates the parsed value and returns a list of validation error messages."""
"""Validates the nested values of this form spec"""
return []

@abc.abstractmethod
def _to_disk(self, raw_value: object, parsed_value: ModelT) -> DataForDisk:
def _to_disk(self, raw_value: object, parsed_value: ParsedValueModel) -> DataForDisk:
"""Transforms the value into a serializable format for disk storage."""
6 changes: 3 additions & 3 deletions cmk/gui/form_specs/vue/visitors/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@

RecomposerFunction = Callable[[FormSpec[Any]], FormSpec[Any]]
form_spec_visitor_registry: dict[
type[FormSpec[Any]], type[FormSpecVisitor[FormSpec[Any], Any]]
type[FormSpec[Any]], type[FormSpecVisitor[FormSpec[Any], Any, Any]]
] = {}

form_spec_recomposer_registry: dict[type[FormSpec[Any]], RecomposerFunction] = {}


def register_visitor_class(
form_spec_class: type[FormSpec[Any]], visitor_class: type[FormSpecVisitor[Any, Any]]
form_spec_class: type[FormSpec[Any]], visitor_class: type[FormSpecVisitor[Any, Any, Any]]
) -> None:
form_spec_visitor_registry[form_spec_class] = visitor_class

Expand All @@ -33,7 +33,7 @@ def register_recomposer_function(

def get_visitor(
form_spec: FormSpec[Any], options: VisitorOptions
) -> FormSpecVisitor[FormSpec[Any], Any]:
) -> FormSpecVisitor[FormSpec[Any], Any, Any]:
if recompose_function := form_spec_recomposer_registry.get(form_spec.__class__):
return get_visitor(recompose_function(form_spec), options)

Expand Down
19 changes: 13 additions & 6 deletions cmk/gui/form_specs/vue/visitors/_type_defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
# conditions defined in the file COPYING, which is part of this source code package.
from dataclasses import dataclass
from enum import auto, Enum
from typing import Any, Optional
from typing import Any, Generic, TypeVar

from cmk.shared_typing.vue_formspec_components import ValidationMessage

DataForDisk = Any
FrontendModel = TypeVar("FrontendModel")
ParsedValueModel = TypeVar("ParsedValueModel")


class DefaultValue:
Expand All @@ -18,6 +20,13 @@ class DefaultValue:
DEFAULT_VALUE = DefaultValue()


class Unset:
pass


UNSET = Unset()


class DataOrigin(Enum):
DISK = auto()
FRONTEND = auto()
Expand All @@ -30,11 +39,9 @@ class VisitorOptions:


@dataclass
class InvalidValue:
reason: Optional[str] = None


INVALID_VALUE = InvalidValue()
class InvalidValue(Generic[FrontendModel]):
fallback_value: FrontendModel
reason: str


class FormSpecValidationError(ValueError):
Expand Down
12 changes: 8 additions & 4 deletions cmk/gui/form_specs/vue/visitors/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from collections.abc import Callable, Sequence
from typing import Any, Protocol, TypeVar

from cmk.gui.form_specs.vue.visitors._type_defs import INVALID_VALUE, InvalidValue
from cmk.gui.form_specs.vue.visitors._type_defs import FrontendModel, InvalidValue
from cmk.gui.htmllib import html
from cmk.gui.i18n import _, translate_to_current_language
from cmk.gui.utils import escaping
Expand Down Expand Up @@ -77,12 +77,16 @@ def compute_validators(form_spec: FormSpec[Any]) -> list[Callable[[Any], object]
return list(form_spec.custom_validate) if form_spec.custom_validate else []


_PrefillTypes = DefaultValue[ModelT] | InputHint[ModelT] | InputHint[Title] | InvalidValue
_PrefillTypes = DefaultValue[ModelT] | InputHint[ModelT] | InputHint[Title]


def get_prefill_default(prefill: _PrefillTypes[ModelT]) -> ModelT | InvalidValue:
def get_prefill_default(
prefill: _PrefillTypes[ModelT], fallback_value: FrontendModel
) -> ModelT | InvalidValue[FrontendModel]:
if not isinstance(prefill, DefaultValue):
return INVALID_VALUE
return InvalidValue[FrontendModel](
reason=_("Prefill value is an input hint"), fallback_value=fallback_value
)
return prefill.value


Expand Down
39 changes: 15 additions & 24 deletions cmk/gui/form_specs/vue/visitors/boolean_choice.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,41 @@
# Copyright (C) 2024 Checkmk GmbH - License: GNU General Public License v2
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
# conditions defined in the file COPYING, which is part of this source code package.
from cmk.ccc.exceptions import MKGeneralException
from cmk.ccc.i18n import _

from cmk.gui.form_specs.vue.validators import build_vue_validators

from cmk.rulesets.v1 import Label, Title
from cmk.rulesets.v1 import Label
from cmk.rulesets.v1.form_specs import BooleanChoice
from cmk.shared_typing import vue_formspec_components as shared_type_defs

from ._base import FormSpecVisitor
from ._type_defs import DEFAULT_VALUE, DefaultValue, INVALID_VALUE, InvalidValue
from ._type_defs import DefaultValue, InvalidValue
from ._utils import (
compute_validation_errors,
compute_validators,
create_validation_error,
get_title_and_help,
localize,
)

type _ParsedValueModel = bool
type _FrontendModel = bool

class BooleanChoiceVisitor(FormSpecVisitor[BooleanChoice, bool]):
def _parse_value(self, raw_value: object) -> bool | InvalidValue:

class BooleanChoiceVisitor(FormSpecVisitor[BooleanChoice, _ParsedValueModel, _FrontendModel]):
def _parse_value(self, raw_value: object) -> _ParsedValueModel | InvalidValue[_FrontendModel]:
if isinstance(raw_value, DefaultValue):
return self.form_spec.prefill.value

if not isinstance(raw_value, bool):
return INVALID_VALUE
return InvalidValue(
reason=_("Invalid choice, falling back to False"), fallback_value=False
)
return raw_value

def _to_vue(
self, raw_value: object, parsed_value: bool | InvalidValue
) -> tuple[shared_type_defs.BooleanChoice, bool]:
self, raw_value: object, parsed_value: _ParsedValueModel | InvalidValue[_FrontendModel]
) -> tuple[shared_type_defs.BooleanChoice, _FrontendModel]:
title, help_text = get_title_and_help(self.form_spec)
assert not isinstance(parsed_value, InvalidValue)
return (
shared_type_defs.BooleanChoice(
title=title,
Expand All @@ -44,19 +46,8 @@ def _to_vue(
text_on=localize(Label("on")),
text_off=localize(Label("off")),
),
parsed_value,
parsed_value.fallback_value if isinstance(parsed_value, InvalidValue) else parsed_value,
)

def _validate(
self, raw_value: object, parsed_value: bool | InvalidValue
) -> list[shared_type_defs.ValidationMessage]:
if isinstance(parsed_value, InvalidValue):
return create_validation_error(
"" if raw_value == DEFAULT_VALUE else raw_value, Title("Invalid boolean choice")
)
return compute_validation_errors(compute_validators(self.form_spec), raw_value)

def _to_disk(self, raw_value: object, parsed_value: bool | InvalidValue) -> bool:
if isinstance(parsed_value, InvalidValue):
raise MKGeneralException("Unable to serialize empty value")
def _to_disk(self, raw_value: object, parsed_value: _ParsedValueModel) -> bool:
return parsed_value
41 changes: 18 additions & 23 deletions cmk/gui/form_specs/vue/visitors/cascading_single_choice.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,55 +7,57 @@

from cmk.gui.form_specs.private import CascadingSingleChoiceExtended
from cmk.gui.form_specs.vue.validators import build_vue_validators
from cmk.gui.i18n import translate_to_current_language
from cmk.gui.i18n import _, translate_to_current_language

from cmk.rulesets.v1 import Title
from cmk.shared_typing import vue_formspec_components as shared_type_defs

from ._base import FormSpecVisitor
from ._registry import get_visitor
from ._type_defs import DEFAULT_VALUE, DefaultValue, INVALID_VALUE, InvalidValue
from ._type_defs import DEFAULT_VALUE, DefaultValue, InvalidValue
from ._utils import (
base_i18n_form_spec,
compute_label,
compute_title_input_hint,
compute_validation_errors,
compute_validators,
create_validation_error,
get_prefill_default,
get_title_and_help,
)

_ParsedValueModel = tuple[str, object]
_FrontendModel = tuple[str, object]


class CascadingSingleChoiceVisitor(
FormSpecVisitor[CascadingSingleChoiceExtended, tuple[str, object]]
FormSpecVisitor[CascadingSingleChoiceExtended, _ParsedValueModel, _FrontendModel]
):
def _parse_value(self, raw_value: object) -> tuple[str, object] | InvalidValue:
def _parse_value(self, raw_value: object) -> _ParsedValueModel | InvalidValue[_FrontendModel]:
if isinstance(raw_value, DefaultValue):
fallback_value: _FrontendModel = ("", None)
if isinstance(
prefill_default := get_prefill_default(self.form_spec.prefill), InvalidValue
prefill_default := get_prefill_default(self.form_spec.prefill, fallback_value),
InvalidValue,
):
return prefill_default
# The default value for a cascading_single_choice element only
# contains the name of the selected element, not the value.
return (prefill_default, DEFAULT_VALUE)
if not isinstance(raw_value, (list, tuple)) or len(raw_value) != 2:
return INVALID_VALUE
return InvalidValue(reason=_("Invalid datatype"), fallback_value=("", None))

name = raw_value[0]
if not any(name == element.name for element in self.form_spec.elements):
return INVALID_VALUE
return InvalidValue(reason=_("Invalid selection"), fallback_value=("", None))

assert isinstance(name, str)
assert len(raw_value) == 2
return tuple(raw_value)

def _to_vue(
self, raw_value: object, parsed_value: tuple[str, object] | InvalidValue
) -> tuple[shared_type_defs.CascadingSingleChoice, tuple[str, object]]:
self, raw_value: object, parsed_value: _ParsedValueModel | InvalidValue[_FrontendModel]
) -> tuple[shared_type_defs.CascadingSingleChoice, _FrontendModel]:
title, help_text = get_title_and_help(self.form_spec)
if isinstance(parsed_value, InvalidValue):
parsed_value = ("", None)
parsed_value = parsed_value.fallback_value

selected_name, selected_value = parsed_value
vue_elements = []
Expand Down Expand Up @@ -91,18 +93,11 @@ def _to_vue(
)

def _validate(
self, raw_value: object, parsed_value: tuple[str, object] | InvalidValue
self, raw_value: object, parsed_value: _ParsedValueModel
) -> list[shared_type_defs.ValidationMessage]:
if isinstance(parsed_value, InvalidValue):
return create_validation_error(raw_value, Title("Invalid selection"))

selected_name, selected_value = parsed_value
element_validations = (
compute_validation_errors(compute_validators(self.form_spec), parsed_value)
if self.form_spec.custom_validate
else []
)

element_validations: list[shared_type_defs.ValidationMessage] = []
for element in self.form_spec.elements:
if selected_name != element.name:
continue
Expand All @@ -119,7 +114,7 @@ def _validate(

return element_validations

def _to_disk(self, raw_value: object, parsed_value: tuple[str, object]) -> tuple[str, object]:
def _to_disk(self, raw_value: object, parsed_value: _ParsedValueModel) -> tuple[str, object]:
selected_name, selected_value = parsed_value

disk_value: Any = None
Expand Down
Loading

0 comments on commit 33b1ab8

Please sign in to comment.