Skip to content

Commit bd14964

Browse files
author
David Martin
committed
Add new global validation using Validation aggregator & errors as dict (to provide more feedback rather than simple msg)
Signed-off-by: David Martin <[email protected]>
1 parent b056040 commit bd14964

16 files changed

+440
-226
lines changed

chaoslib/activity.py

+36-29
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@
1616
validate_python_activity
1717
from chaoslib.provider.process import run_process_activity, \
1818
validate_process_activity
19-
from chaoslib.types import Activity, Configuration, Experiment, Run, Secrets
19+
from chaoslib.types import Activity, Configuration, Experiment, Run, Secrets, \
20+
ValidationError
21+
from chaoslib.validation import Validation
2022

2123

2224
__all__ = ["ensure_activity_is_valid", "get_all_activities_in_experiment",
2325
"run_activities"]
2426

2527

26-
def ensure_activity_is_valid(activity: Activity):
28+
def ensure_activity_is_valid(activity: Activity) -> List[ValidationError]:
2729
"""
2830
Goes through the activity and checks certain of its properties and raise
2931
:exc:`InvalidActivity` whenever one does not respect the expectations.
@@ -34,75 +36,80 @@ def ensure_activity_is_valid(activity: Activity):
3436
3537
Depending on the type, an activity requires a variety of other keys.
3638
37-
In all failing cases, raises :exc:`InvalidActivity`.
39+
In all failing cases, returns a list of validation errors.
3840
"""
39-
errors = []
41+
v = Validation()
4042
if not activity:
41-
errors.append(InvalidActivity("empty activity is no activity"))
42-
return errors
43+
v.add_error("activity", "empty activity is no activity")
44+
return v.errors()
4345

4446
# when the activity is just a ref, there is little to validate
4547
ref = activity.get("ref")
4648
if ref is not None:
4749
if not isinstance(ref, str) or ref == '':
48-
errors.append(InvalidActivity(
49-
"reference to activity must be non-empty strings"))
50-
return errors
50+
v.add_error(
51+
"ref", "reference to activity must be non-empty strings")
52+
return v.errors()
5153

5254
activity_type = activity.get("type")
5355
if not activity_type:
54-
errors.append(InvalidActivity("an activity must have a type"))
56+
v.add_error("type", "an activity must have a type")
5557

5658
if activity_type not in ("probe", "action"):
57-
errors.append(InvalidActivity(
58-
"'{t}' is not a supported activity type".format(t=activity_type)))
59+
msg = "'{t}' is not a supported activity type".format(t=activity_type)
60+
v.add_error("type", msg, value=activity_type)
5961

6062
if not activity.get("name"):
61-
errors.append(InvalidActivity("an activity must have a name"))
63+
v.add_error("name", "an activity must have a name")
6264

6365
provider = activity.get("provider")
6466
if not provider:
65-
errors.append(InvalidActivity("an activity requires a provider"))
67+
v.add_error("provider", "an activity requires a provider")
6668
provider_type = None
6769
else:
6870
provider_type = provider.get("type")
6971
if not provider_type:
70-
errors.append(InvalidActivity("a provider must have a type"))
72+
v.add_error("type", "a provider must have a type")
7173

7274
if provider_type not in ("python", "process", "http"):
73-
errors.append(InvalidActivity(
74-
"unknown provider type '{type}'".format(type=provider_type)))
75+
msg = "unknown provider type '{type}'".format(type=provider_type)
76+
v.add_error("type", msg, value=provider_type)
7577

7678
timeout = activity.get("timeout")
7779
if timeout is not None:
7880
if not isinstance(timeout, numbers.Number):
79-
errors.append(
80-
InvalidActivity("activity timeout must be a number"))
81+
v.add_error(
82+
"timeout", "activity timeout must be a number", value=timeout)
8183

8284
pauses = activity.get("pauses")
8385
if pauses is not None:
8486
before = pauses.get("before")
8587
if before is not None and not isinstance(before, numbers.Number):
86-
errors.append(
87-
InvalidActivity("activity before pause must be a number"))
88+
v.add_error(
89+
"before", "activity before pause must be a number",
90+
value=before
91+
)
8892
after = pauses.get("after")
8993
if after is not None and not isinstance(after, numbers.Number):
90-
errors.append(
91-
InvalidActivity("activity after pause must be a number"))
94+
v.add_error(
95+
"after", "activity after pause must be a number",
96+
value=after
97+
)
9298

9399
if "background" in activity:
94100
if not isinstance(activity["background"], bool):
95-
errors.append(
96-
InvalidActivity("activity background must be a boolean"))
101+
v.add_error(
102+
"background", "activity background must be a boolean",
103+
value=activity["background"])
97104

98105
if provider_type == "python":
99-
errors.extend(validate_python_activity(activity))
106+
v.extend_errors(validate_python_activity(activity))
100107
elif provider_type == "process":
101-
errors.extend(validate_process_activity(activity))
108+
v.extend_errors(validate_process_activity(activity))
102109
elif provider_type == "http":
103-
errors.extend(validate_http_activity(activity))
110+
v.extend_errors(validate_http_activity(activity))
104111

105-
return errors
112+
return v.errors()
106113

107114

108115
def run_activities(experiment: Experiment, configuration: Configuration,

chaoslib/control/__init__.py

+19-13
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from chaoslib.settings import get_loaded_settings
1313
from chaoslib.types import Settings
1414
from chaoslib.types import Activity, Configuration, Control as ControlType, \
15-
Experiment, Hypothesis, Journal, Run, Secrets
15+
Experiment, Hypothesis, Journal, Run, Secrets, ValidationError
16+
from chaoslib.validation import Validation
1617

1718

1819
__all__ = ["controls", "initialize_controls", "cleanup_controls",
@@ -86,11 +87,12 @@ def cleanup_controls(experiment: Experiment):
8687
cleanup_control(control)
8788

8889

89-
def validate_controls(experiment: Experiment) -> List[ChaosException]:
90+
def validate_controls(experiment: Experiment) -> List[ValidationError]:
9091
"""
9192
Validate that all declared controls respect the specification.
9293
"""
93-
errors = []
94+
v = Validation()
95+
9496
controls = get_controls(experiment)
9597
references = [
9698
c["name"] for c in get_controls(experiment)
@@ -99,29 +101,33 @@ def validate_controls(experiment: Experiment) -> List[ChaosException]:
99101
for c in controls:
100102
if "ref" in c:
101103
if c["ref"] not in references:
102-
errors.append(InvalidControl(
103-
"Control reference '{}' declaration cannot be found"))
104+
msg = "Control reference '{}' declaration cannot be found".\
105+
format(c["ref"])
106+
v.add_error("ref", msg, value=c["ref"])
104107

105108
if "name" not in c:
106-
errors.append(
107-
InvalidControl("A control must have a `name` property"))
109+
v.add_error("name", "A control must have a `name` property")
108110

109111
name = c.get("name", '')
110112
if "provider" not in c:
111-
errors.append(InvalidControl(
112-
"Control '{}' must have a `provider` property".format(name)))
113+
v.add_error(
114+
"provider",
115+
"Control '{}' must have a `provider` property".format(name))
113116

114117
scope = c.get("scope")
115118
if scope and scope not in ("before", "after"):
116-
errors.append(InvalidControl(
119+
v.add_error(
120+
"scope",
117121
"Control '{}' scope property must be 'before' or "
118-
"'after' only".format(name)))
122+
"'after' only".format(name),
123+
value=scope
124+
)
119125

120126
provider_type = c.get("provider", {}).get("type")
121127
if provider_type == "python":
122-
errors.extend(validate_python_control(c))
128+
v.extend_errors(validate_python_control(c))
123129

124-
return errors
130+
return v.errors()
125131

126132

127133
def initialize_global_controls(experiment: Experiment,

chaoslib/control/python.py

+10-6
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from chaoslib import substitute
1010
from chaoslib.exceptions import InvalidActivity, ChaosException
1111
from chaoslib.types import Activity, Configuration, Control, Experiment, \
12-
Journal, Run, Secrets, Settings
12+
Journal, Run, Secrets, Settings, ValidationError
13+
from chaoslib.validation import Validation
1314

1415

1516
__all__ = ["apply_python_control", "cleanup_control", "initialize_control",
@@ -83,19 +84,22 @@ def cleanup_control(control: Control):
8384
func()
8485

8586

86-
def validate_python_control(control: Control) -> List[ChaosException]:
87+
def validate_python_control(control: Control) -> List[ValidationError]:
8788
"""
8889
Verify that a control block matches the specification
8990
"""
90-
errors = []
91+
v = Validation()
92+
9193
name = control["name"]
9294
provider = control["provider"]
9395
mod_name = provider.get("module")
9496
if not mod_name:
95-
errors.append(InvalidActivity(
96-
"Control '{}' must have a module path".format(name)))
97+
v.add_error(
98+
"module",
99+
"Control '{}' must have a module path".format(name)
100+
)
97101
# can not continue any longer - must exit this function
98-
return errors
102+
return v.errors()
99103

100104
try:
101105
importlib.import_module(mod_name)

chaoslib/exceptions.py

+1-24
Original file line numberDiff line numberDiff line change
@@ -47,27 +47,4 @@ class InvalidControl(ChaosException):
4747

4848

4949
class ValidationError(ChaosException):
50-
def __init__(self, msg, errors, *args, **kwargs):
51-
"""
52-
:param msg: exception message
53-
:param errors: single error as string or list of errors/exceptions
54-
"""
55-
if isinstance(errors, str):
56-
errors = [errors]
57-
self.errors = errors
58-
super().__init__(msg, *args, **kwargs)
59-
60-
def __str__(self) -> str:
61-
errors = self.errors
62-
nb_errors = len(errors)
63-
err_msg = super().__str__()
64-
return (
65-
"{msg}{dot} {nb} validation error{plural}:\n"
66-
" - {errors}".format(
67-
msg=err_msg,
68-
dot="" if err_msg.endswith(".") else ".",
69-
nb=nb_errors,
70-
plural="" if nb_errors == 1 else "s",
71-
errors="\n - ".join([str(err) for err in errors])
72-
)
73-
)
50+
pass

chaoslib/experiment.py

+39-24
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
cleanup_global_controls
1616
from chaoslib.deprecation import warn_about_deprecated_features
1717
from chaoslib.exceptions import ActivityFailed, ChaosException, \
18-
InterruptExecution, InvalidActivity, InvalidExperiment, ValidationError
18+
InterruptExecution, InvalidActivity, InvalidExperiment, \
19+
ValidationError as ValidationErrorException
1920
from chaoslib.extension import validate_extensions
2021
from chaoslib.configuration import load_configuration
2122
from chaoslib.hypothesis import ensure_hypothesis_is_valid, \
@@ -25,14 +26,17 @@
2526
from chaoslib.secret import load_secrets
2627
from chaoslib.settings import get_loaded_settings
2728
from chaoslib.types import Configuration, Experiment, Journal, Run, Secrets, \
28-
Settings
29+
Settings, ValidationError
30+
from chaoslib.validation import Validation
2931

3032
initialize_global_controls
3133
__all__ = ["ensure_experiment_is_valid", "run_experiment", "load_experiment"]
3234

3335

3436
@with_cache
35-
def ensure_experiment_is_valid(experiment: Experiment):
37+
def ensure_experiment_is_valid(experiment: Experiment,
38+
no_raise: bool = False
39+
) -> List[ValidationError]:
3640
"""
3741
A chaos experiment consists of a method made of activities to carry
3842
sequentially.
@@ -52,62 +56,73 @@ def ensure_experiment_is_valid(experiment: Experiment):
5256
if the experiment is not valid.
5357
If multiple validation errors are found, the errors are listed
5458
as part of the exception message
59+
60+
If `no_raise` is True, the function will not raise any
61+
exception but rather return the list of validation errors
5562
"""
5663
logger.info("Validating the experiment's syntax")
5764

58-
full_validation_msg = 'Experiment is not valid, ' \
59-
'please fix the following errors'
60-
errors = []
65+
full_validation_msg = "Experiment is not valid, " \
66+
"please fix the following errors. " \
67+
"\n{}"
68+
v = Validation()
6169

6270
if not experiment:
71+
v.add_error("$", "an empty experiment is not an experiment")
6372
# empty experiment, cannot continue validation any further
64-
raise ValidationError(full_validation_msg,
65-
"an empty experiment is not an experiment")
73+
if no_raise:
74+
return v.errors()
75+
raise InvalidExperiment(full_validation_msg.format(str(v)))
6676

6777
if not experiment.get("title"):
68-
errors.append(InvalidExperiment("experiment requires a title"))
78+
v.add_error("title", "experiment requires a title")
6979

7080
if not experiment.get("description"):
71-
errors.append(InvalidExperiment("experiment requires a description"))
81+
v.add_error("description", "experiment requires a description")
7282

7383
tags = experiment.get("tags")
7484
if tags:
7585
if list(filter(lambda t: t == '' or not isinstance(t, str), tags)):
76-
errors.append(InvalidExperiment(
77-
"experiment tags must be a non-empty string"))
86+
v.add_error("tags", "experiment tags must be a non-empty string")
7887

79-
errors.extend(validate_extensions(experiment))
88+
v.extend_errors(validate_extensions(experiment))
8089

8190
config = load_configuration(experiment.get("configuration", {}))
8291
load_secrets(experiment.get("secrets", {}), config)
8392

84-
errors.extend(ensure_hypothesis_is_valid(experiment))
93+
v.extend_errors(ensure_hypothesis_is_valid(experiment))
8594

8695
method = experiment.get("method")
8796
if not method:
88-
errors.append(InvalidExperiment("an experiment requires a method with "
89-
"at least one activity"))
97+
v.add_error(
98+
"method",
99+
"an experiment requires a method with at least one activity")
90100
else:
91101
for activity in method:
92-
errors.extend(ensure_activity_is_valid(activity))
102+
v.extend_errors(ensure_activity_is_valid(activity))
93103

94104
# let's see if a ref is indeed found in the experiment
95105
ref = activity.get("ref")
96106
if ref and not lookup_activity(ref):
97-
errors.append(
98-
InvalidActivity("referenced activity '{r}' could not be "
99-
"found in the experiment".format(r=ref)))
107+
v.add_error(
108+
"ref",
109+
"referenced activity '{r}' could not be "
110+
"found in the experiment".format(r=ref),
111+
value=ref
112+
)
100113

101114
rollbacks = experiment.get("rollbacks", [])
102115
for activity in rollbacks:
103-
errors.extend(ensure_activity_is_valid(activity))
116+
v.extend_errors(ensure_activity_is_valid(activity))
104117

105118
warn_about_deprecated_features(experiment)
106119

107-
errors.extend(validate_controls(experiment))
120+
v.extend_errors(validate_controls(experiment))
108121

109-
if errors:
110-
raise ValidationError(full_validation_msg, errors)
122+
if v.has_errors():
123+
if no_raise:
124+
return v.errors()
125+
raise InvalidExperiment(full_validation_msg.format(str(v)))
111126

112127
logger.info("Experiment looks valid")
113128

0 commit comments

Comments
 (0)