diff --git a/chaoslib/activity.py b/chaoslib/activity.py index 689ce3a..d359b4f 100644 --- a/chaoslib/activity.py +++ b/chaoslib/activity.py @@ -16,14 +16,16 @@ validate_python_activity from chaoslib.provider.process import run_process_activity, \ validate_process_activity -from chaoslib.types import Activity, Configuration, Experiment, Run, Secrets +from chaoslib.types import Activity, Configuration, Experiment, Run, Secrets, \ + ValidationError +from chaoslib.validation import Validation __all__ = ["ensure_activity_is_valid", "get_all_activities_in_experiment", "run_activities"] -def ensure_activity_is_valid(activity: Activity): +def ensure_activity_is_valid(activity: Activity) -> List[ValidationError]: """ Goes through the activity and checks certain of its properties and raise :exc:`InvalidActivity` whenever one does not respect the expectations. @@ -34,75 +36,80 @@ def ensure_activity_is_valid(activity: Activity): Depending on the type, an activity requires a variety of other keys. - In all failing cases, raises :exc:`InvalidActivity`. + In all failing cases, returns a list of validation errors. """ - errors = [] + v = Validation() if not activity: - errors.append(InvalidActivity("empty activity is no activity")) - return errors + v.add_error("activity", "empty activity is no activity") + return v.errors() # when the activity is just a ref, there is little to validate ref = activity.get("ref") if ref is not None: if not isinstance(ref, str) or ref == '': - errors.append(InvalidActivity( - "reference to activity must be non-empty strings")) - return errors + v.add_error( + "ref", "reference to activity must be non-empty strings") + return v.errors() activity_type = activity.get("type") if not activity_type: - errors.append(InvalidActivity("an activity must have a type")) + v.add_error("type", "an activity must have a type") if activity_type not in ("probe", "action"): - errors.append(InvalidActivity( - "'{t}' is not a supported activity type".format(t=activity_type))) + msg = "'{t}' is not a supported activity type".format(t=activity_type) + v.add_error("type", msg, value=activity_type) if not activity.get("name"): - errors.append(InvalidActivity("an activity must have a name")) + v.add_error("name", "an activity must have a name") provider = activity.get("provider") if not provider: - errors.append(InvalidActivity("an activity requires a provider")) + v.add_error("provider", "an activity requires a provider") provider_type = None else: provider_type = provider.get("type") if not provider_type: - errors.append(InvalidActivity("a provider must have a type")) + v.add_error("type", "a provider must have a type") if provider_type not in ("python", "process", "http"): - errors.append(InvalidActivity( - "unknown provider type '{type}'".format(type=provider_type))) + msg = "unknown provider type '{type}'".format(type=provider_type) + v.add_error("type", msg, value=provider_type) timeout = activity.get("timeout") if timeout is not None: if not isinstance(timeout, numbers.Number): - errors.append( - InvalidActivity("activity timeout must be a number")) + v.add_error( + "timeout", "activity timeout must be a number", value=timeout) pauses = activity.get("pauses") if pauses is not None: before = pauses.get("before") if before is not None and not isinstance(before, numbers.Number): - errors.append( - InvalidActivity("activity before pause must be a number")) + v.add_error( + "before", "activity before pause must be a number", + value=before + ) after = pauses.get("after") if after is not None and not isinstance(after, numbers.Number): - errors.append( - InvalidActivity("activity after pause must be a number")) + v.add_error( + "after", "activity after pause must be a number", + value=after + ) if "background" in activity: if not isinstance(activity["background"], bool): - errors.append( - InvalidActivity("activity background must be a boolean")) + v.add_error( + "background", "activity background must be a boolean", + value=activity["background"]) if provider_type == "python": - errors.extend(validate_python_activity(activity)) + v.extend_errors(validate_python_activity(activity)) elif provider_type == "process": - errors.extend(validate_process_activity(activity)) + v.extend_errors(validate_process_activity(activity)) elif provider_type == "http": - errors.extend(validate_http_activity(activity)) + v.extend_errors(validate_http_activity(activity)) - return errors + return v.errors() def run_activities(experiment: Experiment, configuration: Configuration, diff --git a/chaoslib/control/__init__.py b/chaoslib/control/__init__.py index 10220bd..1ff4d29 100644 --- a/chaoslib/control/__init__.py +++ b/chaoslib/control/__init__.py @@ -12,7 +12,8 @@ from chaoslib.settings import get_loaded_settings from chaoslib.types import Settings from chaoslib.types import Activity, Configuration, Control as ControlType, \ - Experiment, Hypothesis, Journal, Run, Secrets + Experiment, Hypothesis, Journal, Run, Secrets, ValidationError +from chaoslib.validation import Validation __all__ = ["controls", "initialize_controls", "cleanup_controls", @@ -86,11 +87,12 @@ def cleanup_controls(experiment: Experiment): cleanup_control(control) -def validate_controls(experiment: Experiment) -> List[ChaosException]: +def validate_controls(experiment: Experiment) -> List[ValidationError]: """ Validate that all declared controls respect the specification. """ - errors = [] + v = Validation() + controls = get_controls(experiment) references = [ c["name"] for c in get_controls(experiment) @@ -99,29 +101,33 @@ def validate_controls(experiment: Experiment) -> List[ChaosException]: for c in controls: if "ref" in c: if c["ref"] not in references: - errors.append(InvalidControl( - "Control reference '{}' declaration cannot be found")) + msg = "Control reference '{}' declaration cannot be found".\ + format(c["ref"]) + v.add_error("ref", msg, value=c["ref"]) if "name" not in c: - errors.append( - InvalidControl("A control must have a `name` property")) + v.add_error("name", "A control must have a `name` property") name = c.get("name", '') if "provider" not in c: - errors.append(InvalidControl( - "Control '{}' must have a `provider` property".format(name))) + v.add_error( + "provider", + "Control '{}' must have a `provider` property".format(name)) scope = c.get("scope") if scope and scope not in ("before", "after"): - errors.append(InvalidControl( + v.add_error( + "scope", "Control '{}' scope property must be 'before' or " - "'after' only".format(name))) + "'after' only".format(name), + value=scope + ) provider_type = c.get("provider", {}).get("type") if provider_type == "python": - errors.extend(validate_python_control(c)) + v.extend_errors(validate_python_control(c)) - return errors + return v.errors() def initialize_global_controls(experiment: Experiment, diff --git a/chaoslib/control/python.py b/chaoslib/control/python.py index 6570fa0..d1507fe 100644 --- a/chaoslib/control/python.py +++ b/chaoslib/control/python.py @@ -9,7 +9,8 @@ from chaoslib import substitute from chaoslib.exceptions import InvalidActivity, ChaosException from chaoslib.types import Activity, Configuration, Control, Experiment, \ - Journal, Run, Secrets, Settings + Journal, Run, Secrets, Settings, ValidationError +from chaoslib.validation import Validation __all__ = ["apply_python_control", "cleanup_control", "initialize_control", @@ -83,19 +84,22 @@ def cleanup_control(control: Control): func() -def validate_python_control(control: Control) -> List[ChaosException]: +def validate_python_control(control: Control) -> List[ValidationError]: """ Verify that a control block matches the specification """ - errors = [] + v = Validation() + name = control["name"] provider = control["provider"] mod_name = provider.get("module") if not mod_name: - errors.append(InvalidActivity( - "Control '{}' must have a module path".format(name))) + v.add_error( + "module", + "Control '{}' must have a module path".format(name) + ) # can not continue any longer - must exit this function - return errors + return v.errors() try: importlib.import_module(mod_name) diff --git a/chaoslib/exceptions.py b/chaoslib/exceptions.py index 3697c73..44e66c2 100644 --- a/chaoslib/exceptions.py +++ b/chaoslib/exceptions.py @@ -47,27 +47,4 @@ class InvalidControl(ChaosException): class ValidationError(ChaosException): - def __init__(self, msg, errors, *args, **kwargs): - """ - :param msg: exception message - :param errors: single error as string or list of errors/exceptions - """ - if isinstance(errors, str): - errors = [errors] - self.errors = errors - super().__init__(msg, *args, **kwargs) - - def __str__(self) -> str: - errors = self.errors - nb_errors = len(errors) - err_msg = super().__str__() - return ( - "{msg}{dot} {nb} validation error{plural}:\n" - " - {errors}".format( - msg=err_msg, - dot="" if err_msg.endswith(".") else ".", - nb=nb_errors, - plural="" if nb_errors == 1 else "s", - errors="\n - ".join([str(err) for err in errors]) - ) - ) + pass diff --git a/chaoslib/experiment.py b/chaoslib/experiment.py index 154ad2f..0e39078 100644 --- a/chaoslib/experiment.py +++ b/chaoslib/experiment.py @@ -15,7 +15,8 @@ cleanup_global_controls from chaoslib.deprecation import warn_about_deprecated_features from chaoslib.exceptions import ActivityFailed, ChaosException, \ - InterruptExecution, InvalidActivity, InvalidExperiment, ValidationError + InterruptExecution, InvalidActivity, InvalidExperiment, \ + ValidationError as ValidationErrorException from chaoslib.extension import validate_extensions from chaoslib.configuration import load_configuration from chaoslib.hypothesis import ensure_hypothesis_is_valid, \ @@ -25,14 +26,17 @@ from chaoslib.secret import load_secrets from chaoslib.settings import get_loaded_settings from chaoslib.types import Configuration, Experiment, Journal, Run, Secrets, \ - Settings + Settings, ValidationError +from chaoslib.validation import Validation initialize_global_controls __all__ = ["ensure_experiment_is_valid", "run_experiment", "load_experiment"] @with_cache -def ensure_experiment_is_valid(experiment: Experiment): +def ensure_experiment_is_valid(experiment: Experiment, + no_raise: bool = False + ) -> List[ValidationError]: """ A chaos experiment consists of a method made of activities to carry sequentially. @@ -52,66 +56,78 @@ def ensure_experiment_is_valid(experiment: Experiment): if the experiment is not valid. If multiple validation errors are found, the errors are listed as part of the exception message + + If `no_raise` is True, the function will not raise any + exception but rather return the list of validation errors """ logger.info("Validating the experiment's syntax") - full_validation_msg = 'Experiment is not valid, ' \ - 'please fix the following errors' - errors = [] + full_validation_msg = "Experiment is not valid, " \ + "please fix the following errors. " \ + "\n{}" + v = Validation() if not experiment: + v.add_error("$", "an empty experiment is not an experiment") # empty experiment, cannot continue validation any further - raise ValidationError(full_validation_msg, - "an empty experiment is not an experiment") + if no_raise: + return v.errors() + raise InvalidExperiment(full_validation_msg.format(str(v))) if not experiment.get("title"): - errors.append(InvalidExperiment("experiment requires a title")) + v.add_error("title", "experiment requires a title") if not experiment.get("description"): - errors.append(InvalidExperiment("experiment requires a description")) + v.add_error("description", "experiment requires a description") tags = experiment.get("tags") if tags: if list(filter(lambda t: t == '' or not isinstance(t, str), tags)): - errors.append(InvalidExperiment( - "experiment tags must be a non-empty string")) + v.add_error("tags", "experiment tags must be a non-empty string") - errors.extend(validate_extensions(experiment)) + v.extend_errors(validate_extensions(experiment)) config = load_configuration(experiment.get("configuration", {})) load_secrets(experiment.get("secrets", {}), config) - errors.extend(ensure_hypothesis_is_valid(experiment)) + v.extend_errors(ensure_hypothesis_is_valid(experiment)) method = experiment.get("method") if not method: - errors.append(InvalidExperiment("an experiment requires a method with " - "at least one activity")) + v.add_error( + "method", + "an experiment requires a method with at least one activity") else: for activity in method: - errors.extend(ensure_activity_is_valid(activity)) + v.extend_errors(ensure_activity_is_valid(activity)) # let's see if a ref is indeed found in the experiment ref = activity.get("ref") if ref and not lookup_activity(ref): - errors.append( - InvalidActivity("referenced activity '{r}' could not be " - "found in the experiment".format(r=ref))) + v.add_error( + "ref", + "referenced activity '{r}' could not be " + "found in the experiment".format(r=ref), + value=ref + ) rollbacks = experiment.get("rollbacks", []) for activity in rollbacks: - errors.extend(ensure_activity_is_valid(activity)) + v.extend_errors(ensure_activity_is_valid(activity)) warn_about_deprecated_features(experiment) - errors.extend(validate_controls(experiment)) + v.extend_errors(validate_controls(experiment)) - if errors: - raise ValidationError(full_validation_msg, errors) + if v.has_errors(): + if no_raise: + return v.errors() + raise InvalidExperiment(full_validation_msg.format(str(v))) logger.info("Experiment looks valid") + def initialize_run_journal(experiment: Experiment) -> Journal: return { "chaoslib-version": __version__, diff --git a/chaoslib/extension.py b/chaoslib/extension.py index bb28aa8..55df631 100644 --- a/chaoslib/extension.py +++ b/chaoslib/extension.py @@ -2,13 +2,14 @@ from typing import Optional, List from chaoslib.exceptions import InvalidExperiment, ChaosException -from chaoslib.types import Experiment, Extension +from chaoslib.types import Experiment, Extension, ValidationError +from chaoslib.validation import Validation __all__ = ["get_extension", "has_extension", "set_extension", "merge_extension", "remove_extension", "validate_extensions"] -def validate_extensions(experiment: Experiment) -> List[ChaosException]: +def validate_extensions(experiment: Experiment) -> List[ValidationError]: """ Validate that extensions respect the specification. """ @@ -16,14 +17,15 @@ def validate_extensions(experiment: Experiment) -> List[ChaosException]: if not extensions: return [] - errors = [] + v = Validation() for ext in extensions: ext_name = ext.get('name') if not ext_name or not ext_name.strip(): - errors.append( - InvalidExperiment("All extensions require a non-empty name")) + v.add_error( + "extensions", "All extensions require a non-empty name") + break - return errors + return v.errors() def get_extension(experiment: Experiment, name: str) -> Optional[Extension]: diff --git a/chaoslib/hypothesis.py b/chaoslib/hypothesis.py index 9580069..95d99ea 100644 --- a/chaoslib/hypothesis.py +++ b/chaoslib/hypothesis.py @@ -4,7 +4,7 @@ import json from numbers import Number import re -from typing import Any +from typing import Any, List try: from jsonpath2.path import Path as JSONPath @@ -21,13 +21,15 @@ from chaoslib.exceptions import ActivityFailed, InvalidActivity, \ InvalidExperiment from chaoslib.types import Configuration, Experiment, \ - Secrets, Tolerance + Secrets, Tolerance, ValidationError +from chaoslib.validation import Validation __all__ = ["ensure_hypothesis_is_valid", "run_steady_state_hypothesis"] -def ensure_hypothesis_is_valid(experiment: Experiment): +def ensure_hypothesis_is_valid(experiment: Experiment) \ + -> List[ValidationError]: """ Validates that the steady state hypothesis entry has the expected schema or raises :exc:`InvalidExperiment` or :exc:`InvalidActivity`. @@ -36,139 +38,190 @@ def ensure_hypothesis_is_valid(experiment: Experiment): if hypo is None: return [] - errors = [] + v = Validation() if not hypo.get("title"): - errors.append(InvalidExperiment("hypothesis requires a title")) + v.add_error("title", "hypothesis requires a title") probes = hypo.get("probes") if probes: for probe in probes: - errors.extend(ensure_activity_is_valid(probe)) + v.extend_errors(ensure_activity_is_valid(probe)) if "tolerance" not in probe: - errors.append(InvalidActivity( - "hypothesis probe must have a tolerance entry")) + v.add_error( + "tolerance", + "hypothesis probe must have a tolerance entry") else: - errors.extend( + v.extend_errors( ensure_hypothesis_tolerance_is_valid(probe["tolerance"])) - return errors + return v.errors() -def ensure_hypothesis_tolerance_is_valid(tolerance: Tolerance): +def ensure_hypothesis_tolerance_is_valid(tolerance: Tolerance) \ + -> List[ValidationError]: """ Validate the tolerance of the hypothesis probe and raises :exc:`InvalidActivity` if it isn't valid. """ + v = Validation() if not isinstance(tolerance, ( bool, int, list, str, dict)): - raise InvalidActivity( + v.add_error( + "tolerance", "hypothesis probe tolerance must either be an integer, " "a string, a boolean or a pair of values for boundaries. " "It can also be a dictionary which is a probe activity " "definition that takes an argument called `value` with " - "the value of the probe itself to be validated") + "the value of the probe itself to be validated", + value=tolerance + ) if isinstance(tolerance, dict): tolerance_type = tolerance.get("type") if tolerance_type == "probe": - ensure_activity_is_valid(tolerance) + v.extend_errors(ensure_activity_is_valid(tolerance)) elif tolerance_type == "regex": - check_regex_pattern(tolerance) + v.extend_errors(check_regex_pattern(tolerance)) elif tolerance_type == "jsonpath": - check_json_path(tolerance) + v.extend_errors(check_json_path(tolerance)) elif tolerance_type == "range": - check_range(tolerance) + v.extend_errors(check_range(tolerance)) else: - raise InvalidActivity( + v.add_error( + "type", "hypothesis probe tolerance type '{}' is unsupported".format( - tolerance_type)) + tolerance_type), + value=tolerance_type + ) - # TODO - return [] + return v.errors() -def check_regex_pattern(tolerance: Tolerance): +def check_regex_pattern(tolerance: Tolerance) -> List[ValidationError]: """ Check the regex pattern of a tolerance and raise :exc:`InvalidActivity` when the pattern is missing or invalid (meaning, cannot be compiled by the Python regex engine). """ + v = Validation() + if "pattern" not in tolerance: - raise InvalidActivity( + v.add_error( + "pattern", "hypothesis regex probe tolerance must have a `pattern` key") + return v.errors() pattern = tolerance["pattern"] try: re.compile(pattern) except TypeError: - raise InvalidActivity( + v.add_error( + "pattern", "hypothesis probe tolerance pattern {} has an invalid type".format( - pattern)) + pattern), + value=pattern + ) except re.error as e: - raise InvalidActivity( + v.add_error( + "pattern", "hypothesis probe tolerance pattern {} seems invalid: {}".format( - e.pattern, e.msg)) + e.pattern, e.msg), + value=pattern + ) + return v.errors() -def check_json_path(tolerance: Tolerance): + +def check_json_path(tolerance: Tolerance) -> List[ValidationError]: """ Check the JSON path of a tolerance and raise :exc:`InvalidActivity` when the path is missing or invalid. See: https://github.com/h2non/jsonpath-ng """ + v = Validation() + if not HAS_JSONPATH: raise InvalidActivity( "Install the `jsonpath2` package to use a JSON path tolerance: " "`pip install chaostoolkit-lib[jsonpath]`.") if "path" not in tolerance: - raise InvalidActivity( + v.add_error( + "path", "hypothesis jsonpath probe tolerance must have a `path` key") + return v.errors() try: path = tolerance.get("path", "").strip() if not path: - raise InvalidActivity( - "hypothesis probe tolerance JSON path cannot be empty") + v.add_error( + "path", + "hypothesis probe tolerance JSON path cannot be empty", + value=path + ) JSONPath.parse_str(path) except ValueError: - raise InvalidActivity( + v.add_error( + "path", "hypothesis probe tolerance JSON path {} is invalid".format( - path)) + path), + value=path + ) except TypeError: - raise InvalidActivity( + v.add_error( + "path", "hypothesis probe tolerance JSON path {} has an invalid " - "type".format(path)) + "type".format(path), + value=path + ) + + return v.errors() -def check_range(tolerance: Tolerance): +def check_range(tolerance: Tolerance) -> List[ValidationError]: """ Check a value is within a given range. That range may be set to a min and max value or a sequence. """ + v = Validation() + if "range" not in tolerance: - raise InvalidActivity( + v.add_error( + "range", "hypothesis range probe tolerance must have a `range` key") + return v.errors() the_range = tolerance["range"] if not isinstance(the_range, list): - raise InvalidActivity( + v.add_error( + "range", "hypothesis range must be a sequence") if len(the_range) != 2: - raise InvalidActivity( - "hypothesis range sequence must be made of two values") + v.add_error( + "range", + "hypothesis range sequence must be made of two values", + value=the_range + ) if not isinstance(the_range[0], Number): - raise InvalidActivity( - "hypothesis range lower boundary must be a number") + v.add_error( + "range", + "hypothesis range lower boundary must be a number", + value=the_range[0] + ) if not isinstance(the_range[1], Number): - raise InvalidActivity( - "hypothesis range upper boundary must be a number") + v.add_error( + "range", + "hypothesis range upper boundary must be a number", + value=the_range[1] + ) + + return v.errors() def run_steady_state_hypothesis(experiment: Experiment, diff --git a/chaoslib/provider/http.py b/chaoslib/provider/http.py index 2af37f5..ed98b1a 100644 --- a/chaoslib/provider/http.py +++ b/chaoslib/provider/http.py @@ -7,7 +7,8 @@ from chaoslib import substitute from chaoslib.exceptions import ActivityFailed, InvalidActivity, ChaosException -from chaoslib.types import Activity, Configuration, Secrets +from chaoslib.types import Activity, Configuration, Secrets, ValidationError +from chaoslib.validation import Validation __all__ = ["run_http_activity", "validate_http_activity"] @@ -89,7 +90,7 @@ def run_http_activity(activity: Activity, configuration: Configuration, raise ActivityFailed("activity took too long to complete") -def validate_http_activity(activity: Activity) -> List[ChaosException]: +def validate_http_activity(activity: Activity) -> List[ValidationError]: """ Validate a HTTP activity. @@ -106,16 +107,15 @@ def validate_http_activity(activity: Activity) -> List[ChaosException]: This should be considered as a private function. """ - errors = [] + v = Validation() provider = activity["provider"] url = provider.get("url") if not url: - errors.append(InvalidActivity("a HTTP activity must have a URL")) + v.add_error("url", "a HTTP activity must have a URL") headers = provider.get("headers") if headers and not type(headers) == dict: - errors.append( - InvalidActivity("a HTTP activities expect headers as a mapping")) + v.add_error("headers", "a HTTP activities expect headers as a mapping") - return errors + return v.errors() diff --git a/chaoslib/provider/process.py b/chaoslib/provider/process.py index f83a7c8..6a14d6a 100644 --- a/chaoslib/provider/process.py +++ b/chaoslib/provider/process.py @@ -9,8 +9,9 @@ from logzero import logger from chaoslib import decode_bytes, substitute -from chaoslib.exceptions import ActivityFailed, InvalidActivity, ChaosException -from chaoslib.types import Activity, Configuration, Secrets +from chaoslib.exceptions import ActivityFailed +from chaoslib.types import Activity, Configuration, Secrets, ValidationError +from chaoslib.validation import Validation __all__ = ["run_process_activity", "validate_process_activity"] @@ -67,7 +68,7 @@ def run_process_activity(activity: Activity, configuration: Configuration, } -def validate_process_activity(activity: Activity) -> List[ChaosException]: +def validate_process_activity(activity: Activity) -> List[ValidationError]: """ Validate a process activity. @@ -80,28 +81,31 @@ def validate_process_activity(activity: Activity) -> List[ChaosException]: This should be considered as a private function. """ - errors = [] + v = Validation() name = activity.get("name") provider = activity.get("provider") - path = provider.get("path") + path = rawpath = provider.get("path") if not path: - errors.append(InvalidActivity("a process activity must have a path")) - # cannot validate any further, if no path is defined - return errors + v.add_error("path", "a process activity must have a path". + format(name=name)) + return v.errors() path = shutil.which(path) if not path: - errors.append(InvalidActivity( + v.add_error( + "path", "path '{path}' cannot be found, in activity '{name}'".format( - path=path, name=name))) - # cannot validate any further, if path cannot be found - return errors + path=rawpath, name=name), + value=rawpath) + return v.errors() if not os.access(path, os.X_OK): - errors.append(InvalidActivity( + v.add_error( + "path" "no access permission to '{path}', in activity '{name}'".format( - path=path, name=name))) + path=rawpath, name=name), + value=rawpath) - return errors + return v.errors() diff --git a/chaoslib/provider/python.py b/chaoslib/provider/python.py index f7e6f8a..455e6ed 100644 --- a/chaoslib/provider/python.py +++ b/chaoslib/provider/python.py @@ -9,7 +9,8 @@ from chaoslib import substitute from chaoslib.exceptions import ActivityFailed, InvalidActivity, ChaosException -from chaoslib.types import Activity, Configuration, Secrets +from chaoslib.types import Activity, Configuration, Secrets, ValidationError +from chaoslib.validation import Validation __all__ = ["run_python_activity", "validate_python_activity"] @@ -60,7 +61,7 @@ def run_python_activity(activity: Activity, configuration: Configuration, sys.exc_info()[2]) -def validate_python_activity(activity: Activity) -> List[ChaosException]: +def validate_python_activity(activity: Activity) -> List[ValidationError]: """ Validate a Python activity. @@ -76,33 +77,31 @@ def validate_python_activity(activity: Activity) -> List[ChaosException]: This should be considered as a private function. """ - errors = [] + v = Validation() name = activity.get("name") provider = activity.get("provider") mod_name = provider.get("module") if not mod_name: - errors.append( - InvalidActivity("a Python activity must have a module path")) + v.add_error("module", "a Python activity must have a module path") func = provider.get("func") if not func: - errors.append( - InvalidActivity("a Python activity must have a function name")) + v.add_error("func", "a Python activity must have a function name") if not mod_name or not func: # no module no function, we cannot do anymore validation - return errors + return v.errors() try: mod = importlib.import_module(mod_name) except ImportError: - errors.append( - InvalidActivity("could not find Python module '{mod}' " - "in activity '{name}'".format( - mod=mod_name, name=name))) + msg = "could not find Python module '{mod}' in activity '{name}'"\ + .format(mod=mod_name, name=name) + v.add_error("module", msg, value=mod_name) + # module is not imported, we cannot do any more validation - return errors + return v.errors() found_func = False arguments = provider.get("arguments", {}) @@ -137,23 +136,30 @@ def validate_python_activity(activity: Activity) -> List[ChaosException]: msg = str(x) if "missing" in msg: arg = msg.rsplit(":", 1)[1].strip() - errors.append(InvalidActivity( + v.add_error( + "arguments", "required argument {arg} is missing from " - "activity '{name}'".format(arg=arg, name=name))) + "activity '{name}'".format(arg=arg, name=name), + ) elif "unexpected" in msg: arg = msg.rsplit(" ", 1)[1].strip() - errors.append(InvalidActivity( + v.add_error( + "arguments", "argument {arg} is not part of the " "function signature in activity '{name}'".format( - arg=arg, name=name))) + arg=arg, name=name) + ) else: # another error? let's fail fast raise break if not found_func: - errors.append(InvalidActivity( + v.add_error( + "func", "'{mod}' does not expose '{func}' in activity '{name}'".format( - mod=mod_name, func=func, name=name))) + mod=mod_name, func=func, name=name), + value=func + ) - return errors + return v.errors() diff --git a/chaoslib/types.py b/chaoslib/types.py index a8eed67..4a3d407 100644 --- a/chaoslib/types.py +++ b/chaoslib/types.py @@ -5,7 +5,7 @@ "TargetLayers", "Activity", "Journal", "Run", "Secrets", "Step", "Configuration", "Discovery", "DiscoveredActivities", "Extension", "DiscoveredSystemInfo", "Settings", "EventPayload", "Tolerance", - "Hypothesis", "Control"] + "Hypothesis", "Control", "ValidationError"] Action = Dict[str, Any] @@ -37,3 +37,5 @@ Extension = Dict[str, Any] Hypothesis = Dict[str, Any] Control = Dict[str, Any] + +ValidationError = Dict[str, Any] diff --git a/chaoslib/validation.py b/chaoslib/validation.py new file mode 100644 index 0000000..e5bd748 --- /dev/null +++ b/chaoslib/validation.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +import json +from typing import List, Dict, Any, Union + +from chaoslib.types import ValidationError + +__all__ = ["Validation"] + + +class Validation(object): + """ + This class keeps a list of validation errors to be appended + to report all errors at once + """ + def __init__(self): + self._errors: List[ValidationError] = [] + + def add_error(self, field: str, message: str, + value: Any = None, location: tuple = None): + """ + Append a new error to the validation errors list + + :param field: name of the key/field that raised the validation error + :param message: information about the error + :param value: value being validated - that failed - + :param location: optional location of the key in the full document + as a json-path-like; e.g. ("rollbacks", 0, "type") + NB: For list/tuples, the index of the failed sub-element is used + """ + if location is None: + location = () + + self._errors.append( + { + "field": field, + "msg": message, + "value": value, + "loc": location, + } + ) + + def has_errors(self) -> bool: + return len(self.errors()) + + def extend_errors(self, errors: List[ValidationError]): + self._errors.extend(errors) + + def merge(self, val: 'Validation'): + self.extend_errors(val.errors()) + + def errors(self) -> List[ValidationError]: + return self._errors + + def json(self, indent: Union[None, int, str] = 2) -> str: + return json.dumps(self.errors(), indent=indent) + + def display_errors(self): + return _display_errors(self.errors()) + + def __str__(self) -> str: + errors = self.errors() + nb_errors = len(errors) + return ( + "{count} validation error{plural}\n" + "{errors}".format( + count=nb_errors, plural="" if nb_errors == 1 else "s", + errors=_display_errors(errors) + ) + ) + + +def _display_errors(errors: List[ValidationError]) -> str: + error_format = "{field}\n\t{msg} -> {loc} ({val})" + return '\n'.join(error_format.format( + field=e["field"], msg=e["msg"], val=e["value"], + loc=_display_error_loc(e)) for e in errors) + + +def _display_error_loc(error: ValidationError) -> str: + return '.'.join(str(l) for l in error['loc']) diff --git a/tests/test_control.py b/tests/test_control.py index 49aa553..4781fe3 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -239,7 +239,6 @@ def test_validate_python_control_needs_a_module(): } }) assert len(errors) - assert type(errors[0]) == InvalidActivity def test_controls_can_access_experiment(): diff --git a/tests/test_probe.py b/tests/test_probe.py index 300aee3..0e67890 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -12,21 +12,7 @@ from chaoslib.activity import ensure_activity_is_valid, run_activity from fixtures import config, experiments, probes - - -def assert_in_errors(msg, errors): - """ - Check whether msg can be found in any of the list of errors - - :param msg: exception string to be found in any of the instances - :param errors: list of ChaosException instances - """ - for error in errors: - if msg in str(error): - # expected exception message is found - return - - raise AssertionError("{} not in {}".format(msg, errors)) +from test_validation import assert_in_errors def test_empty_probe_is_invalid(): @@ -82,10 +68,8 @@ def test_process_probe_have_a_path(): def test_process_probe_path_must_exist(): - print(probes.ProcessPathDoesNotExistProbe) errors = ensure_activity_is_valid(probes.ProcessPathDoesNotExistProbe) - print(errors) - assert_in_errors("path 'None' cannot be found, in activity", errors) + assert_in_errors("path 'somewhere/not/here' cannot be found, in activity", errors) def test_http_probe_must_have_a_url(): diff --git a/tests/test_tolerance.py b/tests/test_tolerance.py index 22c0183..2cdd011 100644 --- a/tests/test_tolerance.py +++ b/tests/test_tolerance.py @@ -7,6 +7,9 @@ within_tolerance +from test_validation import assert_in_errors + + def test_tolerance_int(): assert within_tolerance(6, value=6) is True @@ -266,8 +269,8 @@ def test_tolerance_jsonpath_cannot_be_empty(): "path": "" } - with pytest.raises(InvalidActivity): - ensure_hypothesis_tolerance_is_valid(t) + errors = ensure_hypothesis_tolerance_is_valid(t) + assert len(errors) def test_tolerance_regex_stdout_process(): @@ -373,40 +376,36 @@ def test_tolerance_regex_body_http(): def test_tolerance_regex_must_have_a_pattern(): - with pytest.raises(InvalidActivity) as e: - ensure_hypothesis_tolerance_is_valid({ + errors = ensure_hypothesis_tolerance_is_valid({ "type": "regex", "target": "stdout" }) - assert "tolerance must have a `pattern` key" in str(e.value) + assert_in_errors("tolerance must have a `pattern` key", errors) def test_tolerance_regex_must_have_a_valid_pattern_type(): - with pytest.raises(InvalidActivity) as e: - ensure_hypothesis_tolerance_is_valid({ + errors = ensure_hypothesis_tolerance_is_valid({ "type": "regex", "target": "stdout", "pattern": None }) - assert "tolerance pattern None has an invalid type" in str(e.value) + assert_in_errors("tolerance pattern None has an invalid type", errors) def test_tolerance_regex_must_have_a_valid_pattern(): - with pytest.raises(InvalidActivity) as e: - ensure_hypothesis_tolerance_is_valid({ + errors = ensure_hypothesis_tolerance_is_valid({ "type": "regex", "target": "stdout", "pattern": "[0-9" }) - assert "pattern [0-9 seems invalid" in str(e.value) + assert_in_errors("pattern [0-9 seems invalid", errors) def test_tolerance_unsupported_type(): - with pytest.raises(InvalidActivity) as e: - ensure_hypothesis_tolerance_is_valid({ + errors = ensure_hypothesis_tolerance_is_valid({ "type": "boom" }) - assert "tolerance type 'boom' is unsupported" in str(e.value) + assert_in_errors("tolerance type 'boom' is unsupported", errors) def test_tolerance_missing_jsonpath_backend(): @@ -517,8 +516,8 @@ def test_tolerance_range_lower_boundary_must_be_a_number(): "target": "body", "range": ["a", 6] } - with pytest.raises(InvalidActivity): - ensure_hypothesis_tolerance_is_valid(t) + errors = ensure_hypothesis_tolerance_is_valid(t) + assert len(errors) def test_tolerance_range_upper_boundary_must_be_a_number(): @@ -527,8 +526,8 @@ def test_tolerance_range_upper_boundary_must_be_a_number(): "target": "body", "range": [6, "b"] } - with pytest.raises(InvalidActivity): - ensure_hypothesis_tolerance_is_valid(t) + errors = ensure_hypothesis_tolerance_is_valid(t) + assert len(errors) def test_tolerance_range_checked_value_must_be_a_number(): diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 0000000..ef661a5 --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +import pytest + +from chaoslib.validation import Validation + + +def assert_in_errors(msg, errors): + """ + Check whether msg can be found in any of the list of errors + + :param msg: exception string to be found in any of the instances + :param errors: list of ChaosException instances + """ + print(errors) + for error in errors: + if msg in error["msg"]: + # expected exception message is found + return + + raise AssertionError("{} not in {}".format(msg, errors)) + + +# +# v = Validation() +# v.add_error("key", "invalid value", value=5, location=("test", 0, "key")) +# v.add_error("other", "missing required value", value=None, location=("name",)) +# v.add_error("dummy", "this is an error") +# from logzero import logger +# logger.debug('Experiment is invalid. \n{errors}'.format(errors=str(v))) +# # print(v) + +def test_add_error(): + v = Validation() + v.add_error("test", "this is an error message") + assert v.has_errors() + + +def test_extend_errors(): + v = Validation() + v.add_error("test", "this is an error message") + v.extend_errors([{'dummy': 'error'}, {'another': 'error'}]) + assert len(v.errors()) == 3 + + +def test_display_errors(): + v = Validation() + v.add_error("test", "this is an error message", value=False, + location=("a", "b", "c")) + str = v.display_errors() + assert len(str) + + +def test_errors_as_json(): + v = Validation() + v.add_error("test", "this is an error message", value=False, + location=("a", "b", "c")) + json = v.json() + assert len(json) + + +def test_merge_validation(): + v1 = Validation() + v1.add_error("test", "this is an error message") + + v2 = Validation() + v2.add_error("test", "this is another error message") + + v1.merge(v2) + assert len(v1.errors()) == 2 + + +def test_str_output(): + v = Validation() + v.add_error("test", "this is an error message", value=False, + location=("a", "b", "c")) + assert str(v)