15
15
# limitations under the License.
16
16
#
17
17
18
+ import ast
19
+ import builtins
20
+ import inspect
18
21
import math
19
22
import re
20
23
import string
21
24
from abc import ABC , abstractmethod
22
25
from typing import List
23
26
24
27
from .errors import TestStepError
28
+ from .fixes import fix_typed_yaml_value
29
+
30
+
31
+ def print_to_log (* objects , sep = ' ' , end = None ):
32
+ # Try to fit in with the test logger output format
33
+ print ('\n \t \t ' + sep .join (str (arg ) for arg in objects ))
25
34
26
35
27
36
class ConstraintParseError (Exception ):
@@ -121,6 +130,11 @@ def __init__(self, context, reason):
121
130
super ().__init__ (context , 'anyOf' , reason )
122
131
123
132
133
+ class ConstraintPythonError (ConstraintCheckError ):
134
+ def __init__ (self , context , reason ):
135
+ super ().__init__ (context , 'python' , reason )
136
+
137
+
124
138
class BaseConstraint (ABC ):
125
139
'''Constraint Interface'''
126
140
@@ -130,7 +144,7 @@ def __init__(self, context, types: list, is_null_allowed: bool = False):
130
144
self ._is_null_allowed = is_null_allowed
131
145
self ._context = context
132
146
133
- def validate (self , value , value_type_name ):
147
+ def validate (self , value , value_type_name , runtime_variables ):
134
148
if value is None and self ._is_null_allowed :
135
149
return
136
150
@@ -194,6 +208,8 @@ def _raise_error(self, reason):
194
208
raise ConstraintNotValueError (self ._context , reason )
195
209
elif isinstance (self , _ConstraintAnyOf ):
196
210
raise ConstraintAnyOfError (self ._context , reason )
211
+ elif isinstance (self , _ConstraintPython ):
212
+ raise ConstraintPythonError (self ._context , reason )
197
213
else :
198
214
# This should not happens.
199
215
raise ConstraintParseError ('Unknown constraint instance.' )
@@ -204,7 +220,7 @@ def __init__(self, context, has_value):
204
220
super ().__init__ (context , types = [])
205
221
self ._has_value = has_value
206
222
207
- def validate (self , value , value_type_name ):
223
+ def validate (self , value , value_type_name , runtime_variables ):
208
224
# We are overriding the BaseConstraint of validate since has value is a special case where
209
225
# we might not be expecting a value at all, but the basic null check in BaseConstraint
210
226
# is not what we want.
@@ -824,6 +840,46 @@ def get_reason(self, value, value_type_name) -> str:
824
840
return f'The response value "{ value } " is not a value from { self ._any_of } .'
825
841
826
842
843
+ class _ConstraintPython (BaseConstraint ):
844
+ def __init__ (self , context , source : str ):
845
+ super ().__init__ (context , types = [], is_null_allowed = False )
846
+
847
+ # Parse the source as the body of a function
848
+ if '\n ' not in source : # treat single line code like a lambda
849
+ source = 'return (' + source + ')\n '
850
+ parsed = ast .parse (source )
851
+ module = ast .parse ('def _func(value): pass' )
852
+ module .body [0 ].body = parsed .body # inject parsed body
853
+ self ._ast = module
854
+
855
+ def validate (self , value , value_type_name , runtime_variables ):
856
+ # Build a global scope that includes all runtime variables
857
+ scope = {name : fix_typed_yaml_value (value ) for name , value in runtime_variables .items ()}
858
+ scope ['__builtins__' ] = self .BUILTINS
859
+ # Execute the module AST and extract the defined function
860
+ exec (compile (self ._ast , '<string>' , 'exec' ), scope )
861
+ func = scope ['_func' ]
862
+ # Call the function to validate the value
863
+ try :
864
+ valid = func (value )
865
+ except Exception as ex :
866
+ self ._raise_error (f'Python constraint { type (ex ).__name__ } : { ex } ' )
867
+ if type (valid ) is not bool :
868
+ self ._raise_error ("Python constraint TypeError: must return a bool" )
869
+ if not valid :
870
+ self ._raise_error (f'The response value "{ value } " is not valid' )
871
+
872
+ def check_response (self , value , value_type_name ) -> bool : pass # unused
873
+ def get_reason (self , value , value_type_name ) -> str : pass # unused
874
+
875
+ # Explicitly list allowed functions / constants, avoid things like exec, eval, import. Classes are generally safe.
876
+ ALLOWED_BUILTINS = ['True' , 'False' , 'None' , 'abs' , 'all' , 'any' , 'ascii' , 'bin' , 'chr' , 'divmod' , 'enumerate' , 'filter' , 'format' ,
877
+ 'hex' , 'isinstance' , 'issubclass' , 'iter' , 'len' , 'max' , 'min' , 'next' , 'oct' , 'ord' , 'pow' , 'repr' , 'round' , 'sorted' , 'sum' ]
878
+ BUILTINS = (dict (inspect .getmembers (builtins , inspect .isclass )) |
879
+ {name : getattr (builtins , name ) for name in ALLOWED_BUILTINS } |
880
+ {'print' : print_to_log })
881
+
882
+
827
883
def get_constraints (constraints : dict ) -> List [BaseConstraint ]:
828
884
_constraints = []
829
885
context = constraints
@@ -879,6 +935,9 @@ def get_constraints(constraints: dict) -> List[BaseConstraint]:
879
935
elif 'anyOf' == constraint :
880
936
_constraints .append (_ConstraintAnyOf (
881
937
context , constraint_value ))
938
+ elif 'python' == constraint :
939
+ _constraints .append (_ConstraintPython (
940
+ context , constraint_value ))
882
941
else :
883
942
raise ConstraintParseError (f'Unknown constraint type:{ constraint } ' )
884
943
@@ -904,9 +963,14 @@ def is_typed_constraint(constraint: str):
904
963
'hasMasksClear' : False ,
905
964
'notValue' : True ,
906
965
'anyOf' : True ,
966
+ 'python' : False ,
907
967
}
908
968
909
969
is_typed = constraints .get (constraint )
910
970
if is_typed is None :
911
971
raise ConstraintParseError (f'Unknown constraint type:{ constraint } ' )
912
972
return is_typed
973
+
974
+
975
+ def is_variable_aware_constraint (constraint : str ):
976
+ return constraint == 'python'
0 commit comments