Skip to content

Commit

Permalink
PSCE-239: feat: adds validation for YAML trestle rules (#52)
Browse files Browse the repository at this point in the history
* feat(transformers): initial validation handler from pre-validation before pydantic

Signed-off-by: Jennifer Power <[email protected]>

* chore: fixes spelling error on csv_transformer.py comment

Signed-off-by: Jennifer Power <[email protected]>

* feat(task): adds collection of validation errors in rules task

Updates the rules transform task to make sure all errors for a
component defintion are collection during transformation and presented
back to the user to make errors easier to address at one time.

Signed-off-by: Jennifer Power <[email protected]>

---------

Signed-off-by: Jennifer Power <[email protected]>
  • Loading branch information
jpower432 committed Oct 12, 2023
1 parent b522388 commit 11a1a7c
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 4 deletions.
60 changes: 60 additions & 0 deletions tests/trestlebot/transformers/test_validations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/python

# Copyright 2023 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

"""Test for Validations."""
from typing import Any, Dict

from trestlebot.transformers.validations import (
ValidationHandler,
ValidationOutcome,
parameter_validation,
)


def test_parameter_validation(valid_rule_data: Dict[str, Any]) -> None:
"""Test parameter validation with valid data."""
result: ValidationOutcome = ValidationOutcome(errors=[], valid=True)
parameter_validation(valid_rule_data, result)
assert result.valid


def test_parameter_validation_with_error(
invalid_param_rule_data: Dict[str, Any]
) -> None:
"""Test parameter validation with invalid parameter."""
result: ValidationOutcome = ValidationOutcome(errors=[], valid=True)
parameter_validation(invalid_param_rule_data, result)
assert not result.valid
assert len(result.errors) == 1
assert (
result.errors[0].error_message
== "Default value must be one of the alternative values"
)


def test_parameter_validation_with_handler(
invalid_param_rule_data: Dict[str, Any]
) -> None:
"""Test parameter validation with handler."""
result: ValidationOutcome = ValidationOutcome(errors=[], valid=True)
handler = ValidationHandler(parameter_validation)
handler.handle(invalid_param_rule_data, result)
assert not result.valid
assert len(result.errors) == 1
assert (
result.errors[0].error_message
== "Default value must be one of the alternative values"
)
19 changes: 19 additions & 0 deletions tests/trestlebot/transformers/test_yaml_transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from tests.testutils import YAML_TEST_DATA_PATH
from trestlebot.transformers.base_transformer import RulesTransformerException
from trestlebot.transformers.trestle_rule import TrestleRule
from trestlebot.transformers.validations import ValidationHandler, parameter_validation
from trestlebot.transformers.yaml_transformer import (
FromRulesYAMLTransformer,
ToRulesYAMLTransformer,
Expand Down Expand Up @@ -88,6 +89,24 @@ def test_rules_transform_with_invalid_rule() -> None:
transformer.transform(rule_file_info)


def test_rules_transform_with_additional_validation() -> None:
"""Test rules transform with additional validation."""
# load rule from path and close the file
# get the file info as a string
rule_path = YAML_TEST_DATA_PATH / "test_rule_invalid_params.yaml"
rule_file = open(rule_path, "r")
rule_file_info = rule_file.read()
rule_file.close()
validation_handler_chain = ValidationHandler(parameter_validation)
transformer = ToRulesYAMLTransformer(validation_handler_chain)

with pytest.raises(
RulesTransformerException,
match=".*Default value must be one of the alternative values",
):
transformer.transform(rule_file_info)


def test_read_write_integration(test_rule: TrestleRule) -> None:
"""Test read/write integration."""
from_rules_transformer = FromRulesYAMLTransformer()
Expand Down
12 changes: 11 additions & 1 deletion trestlebot/tasks/rule_transform_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ def _transform_components(self, component_definition_path: pathlib.Path) -> None
logger.debug(
f"Transforming rules for component definition {component_definition_path.name}"
)

# To report all rule errors at once, we collect them in a list and
# pretty print them in a raised exception
transformation_errors: List[str] = []
for component in self.iterate_models(component_definition_path):
for rule_path in self.iterate_models(component):
# Load the rule into memory as a stream to process
Expand All @@ -102,10 +106,16 @@ def _transform_components(self, component_definition_path: pathlib.Path) -> None
rule = self._rule_transformer.transform(rule_stream)
csv_builder.add_row(rule)
except RulesTransformerException as e:
raise TaskException(
transformation_errors.append(
f"Failed to transform rule {rule_path.name}: {e}"
)

if len(transformation_errors) > 0:
raise TaskException(
f"Failed to transform rules for component definition {component_definition_path.name}: \
\n{', '.join(transformation_errors)}"
)

if csv_builder.row_count == 0:
raise TaskException(
f"No rules found for component definition {component_definition_path.name}"
Expand Down
2 changes: 1 addition & 1 deletion trestlebot/transformers/csv_transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
# License for the specific language governing permissions and limitations
# under the License.

"""CSV Tranformer for rule authoring."""
"""CSV Transformer for rule authoring."""

import csv
import json
Expand Down
100 changes: 100 additions & 0 deletions trestlebot/transformers/validations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/python

# Copyright 2023 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.

"""
Trestle Validation for rule authoring.
This is meant to be extensible for future validations.
Base rule validation and utility functions are defined here.
"""

import logging
from typing import Any, Callable, Dict, List, Optional

from pydantic import BaseModel

from trestlebot import const


logger = logging.getLogger(__name__)


class RuleValidationError(BaseModel):
"""RuleValidationError model."""

field_name: str
error_message: str


class ValidationOutcome(BaseModel):
"""ValidationOutcome model."""

errors: List[RuleValidationError]
valid: bool


class ValidationHandler:
def __init__( # type: ignore
self, validate_fn: Callable[[Any, ValidationOutcome], None], next_handler=None
) -> None:
self.validate_fn: Callable[[Any, ValidationOutcome], None] = validate_fn
self.next_handler: Optional[ValidationHandler] = next_handler

def handle(self, data: Any, result: ValidationOutcome) -> None:
self.validate_fn(data, result)
if self.next_handler:
self.next_handler.handle(data, result)


# TODO(jpower432): Create a class for Profile validation to ensure unique
# entries exists in the workspace.


def parameter_validation(data: Dict[str, Any], result: ValidationOutcome) -> None:
"""Parameter logic additions validation."""
rule_info: Dict[str, Any] = data.get(const.RULE_INFO_TAG, {})
parameter_data: Dict[str, Any] = rule_info.get(const.PARAMETER, {})

if not parameter_data:
logger.debug("No parameter data found")
return # No parameter data, nothing to validate

default_value = parameter_data.get(const.DEFAULT_VALUE, "")
alternative_values: Dict[str, Any] = parameter_data.get(
const.ALTERNATIVE_VALUES, {}
)

if not default_value:
add_validation_error(result, const.PARAMETER, "Default value is required")

if not alternative_values:
add_validation_error(result, const.PARAMETER, "Alternative values are required")

default_value_alt = alternative_values.get("default", "")

if not default_value_alt or default_value_alt != default_value:
add_validation_error(
result,
const.PARAMETER,
"Default value must be one of the alternative values",
)


def add_validation_error(result: ValidationOutcome, field: str, error_msg: str) -> None:
"""Add a validation error to the result."""
validation_error = RuleValidationError(field_name=field, error_message=error_msg)
result.errors.append(validation_error)
result.valid = False
15 changes: 13 additions & 2 deletions trestlebot/transformers/yaml_transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import logging
import pathlib
from io import StringIO
from typing import Any, Dict
from typing import Any, Dict, Optional

from pydantic import ValidationError
from ruamel.yaml import YAML
Expand All @@ -35,6 +35,7 @@
Profile,
TrestleRule,
)
from trestlebot.transformers.validations import ValidationHandler, ValidationOutcome


logger = logging.getLogger(__name__)
Expand All @@ -43,8 +44,9 @@
class ToRulesYAMLTransformer(ToRulesTransformer):
"""Interface for YAML transformer to Rules model."""

def __init__(self) -> None:
def __init__(self, validator: Optional[ValidationHandler] = None) -> None:
"""Initialize."""
self.validator: Optional[ValidationHandler] = validator
super().__init__()

def transform(self, blob: str) -> TrestleRule:
Expand All @@ -53,6 +55,15 @@ def transform(self, blob: str) -> TrestleRule:
yaml = YAML(typ="safe")
yaml_data: Dict[str, Any] = yaml.load(blob)

logger.debug("Executing pre-validation on YAML data")
if self.validator is not None:
result = ValidationOutcome(errors=[], valid=True)
self.validator.handle(yaml_data, result)
if not result.valid:
raise RulesTransformerException(
f"Invalid YAML file: {result.errors}"
)

rule_info_data = yaml_data[const.RULE_INFO_TAG]

profile_data = rule_info_data[const.PROFILE]
Expand Down

0 comments on commit 11a1a7c

Please sign in to comment.