Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

PSCE-239: feat: adds validation for YAML trestle rules #52

Merged
merged 3 commits into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading