Skip to content

Commit faef75c

Browse files
Yaml Test Runner: Add support for Python constraints (#34487)
* Yaml Test Runner: Add support for Python constraints * Restyle / appease the linter * Add to schema documentation
1 parent 514b9eb commit faef75c

File tree

6 files changed

+115
-5
lines changed

6 files changed

+115
-5
lines changed

docs/testing/yaml_schema.md

+1
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ YAML schema
6262
|      hasMasksClear |list||
6363
|      notValue |NoneType,bool,int,float,list,dict|Y|
6464
|      anyOf |list||
65+
|      python |str|Y|
6566
|    saveAs |str||
6667
|    saveDataVersschemaionAs |str||
6768
|  saveResponseAs |str||

scripts/py_matter_yamltests/matter_yamltests/constraints.py

+66-2
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,22 @@
1515
# limitations under the License.
1616
#
1717

18+
import ast
19+
import builtins
20+
import inspect
1821
import math
1922
import re
2023
import string
2124
from abc import ABC, abstractmethod
2225
from typing import List
2326

2427
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))
2534

2635

2736
class ConstraintParseError(Exception):
@@ -121,6 +130,11 @@ def __init__(self, context, reason):
121130
super().__init__(context, 'anyOf', reason)
122131

123132

133+
class ConstraintPythonError(ConstraintCheckError):
134+
def __init__(self, context, reason):
135+
super().__init__(context, 'python', reason)
136+
137+
124138
class BaseConstraint(ABC):
125139
'''Constraint Interface'''
126140

@@ -130,7 +144,7 @@ def __init__(self, context, types: list, is_null_allowed: bool = False):
130144
self._is_null_allowed = is_null_allowed
131145
self._context = context
132146

133-
def validate(self, value, value_type_name):
147+
def validate(self, value, value_type_name, runtime_variables):
134148
if value is None and self._is_null_allowed:
135149
return
136150

@@ -194,6 +208,8 @@ def _raise_error(self, reason):
194208
raise ConstraintNotValueError(self._context, reason)
195209
elif isinstance(self, _ConstraintAnyOf):
196210
raise ConstraintAnyOfError(self._context, reason)
211+
elif isinstance(self, _ConstraintPython):
212+
raise ConstraintPythonError(self._context, reason)
197213
else:
198214
# This should not happens.
199215
raise ConstraintParseError('Unknown constraint instance.')
@@ -204,7 +220,7 @@ def __init__(self, context, has_value):
204220
super().__init__(context, types=[])
205221
self._has_value = has_value
206222

207-
def validate(self, value, value_type_name):
223+
def validate(self, value, value_type_name, runtime_variables):
208224
# We are overriding the BaseConstraint of validate since has value is a special case where
209225
# we might not be expecting a value at all, but the basic null check in BaseConstraint
210226
# is not what we want.
@@ -824,6 +840,46 @@ def get_reason(self, value, value_type_name) -> str:
824840
return f'The response value "{value}" is not a value from {self._any_of}.'
825841

826842

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+
827883
def get_constraints(constraints: dict) -> List[BaseConstraint]:
828884
_constraints = []
829885
context = constraints
@@ -879,6 +935,9 @@ def get_constraints(constraints: dict) -> List[BaseConstraint]:
879935
elif 'anyOf' == constraint:
880936
_constraints.append(_ConstraintAnyOf(
881937
context, constraint_value))
938+
elif 'python' == constraint:
939+
_constraints.append(_ConstraintPython(
940+
context, constraint_value))
882941
else:
883942
raise ConstraintParseError(f'Unknown constraint type:{constraint}')
884943

@@ -904,9 +963,14 @@ def is_typed_constraint(constraint: str):
904963
'hasMasksClear': False,
905964
'notValue': True,
906965
'anyOf': True,
966+
'python': False,
907967
}
908968

909969
is_typed = constraints.get(constraint)
910970
if is_typed is None:
911971
raise ConstraintParseError(f'Unknown constraint type:{constraint}')
912972
return is_typed
973+
974+
975+
def is_variable_aware_constraint(constraint: str):
976+
return constraint == 'python'

scripts/py_matter_yamltests/matter_yamltests/fixes.py

+22
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,28 @@ def convert_yaml_octet_string_to_bytes(s: str) -> bytes:
102102
return binascii.unhexlify(accumulated_hex)
103103

104104

105+
def fix_typed_yaml_value(value):
106+
"""Applies fixups to typed runtime variables if necessary."""
107+
if type(value) is dict:
108+
mapping_type = value.get('type')
109+
default_value = value.get('defaultValue')
110+
if mapping_type is not None and default_value is not None:
111+
value = default_value
112+
if mapping_type == 'int64u' or mapping_type == 'int64s' or mapping_type == 'bitmap64' or mapping_type == 'epoch_us':
113+
value = try_apply_float_to_integer_fix(value)
114+
value = try_apply_yaml_cpp_longlong_limitation_fix(value)
115+
value = try_apply_yaml_unrepresentable_integer_for_javascript_fixes(value)
116+
elif mapping_type == 'single' or mapping_type == 'double':
117+
value = try_apply_yaml_float_written_as_strings(value)
118+
elif isinstance(value, float) and mapping_type != 'single' and mapping_type != 'double':
119+
value = try_apply_float_to_integer_fix(value)
120+
elif mapping_type == 'octet_string' or mapping_type == 'long_octet_string':
121+
value = convert_yaml_octet_string_to_bytes(value)
122+
elif mapping_type == 'boolean':
123+
value = bool(value)
124+
return value
125+
126+
105127
def add_yaml_support_for_scientific_notation_without_dot(loader):
106128
regular_expression = re.compile(u'''^(?:
107129
[-+]?(?:[0-9][0-9_]*)\\.[0-9_]*(?:[eE][-+]?[0-9]+)?

scripts/py_matter_yamltests/matter_yamltests/parser.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from typing import Optional
2222

2323
from . import fixes
24-
from .constraints import get_constraints, is_typed_constraint
24+
from .constraints import get_constraints, is_typed_constraint, is_variable_aware_constraint
2525
from .definitions import SpecDefinitions
2626
from .errors import (TestStepEnumError, TestStepEnumSpecifierNotUnknownError, TestStepEnumSpecifierWrongError, TestStepError,
2727
TestStepKeyError, TestStepValueNameError)
@@ -1157,7 +1157,7 @@ def _response_constraints_validation(self, expected_response, received_response,
11571157

11581158
for constraint in constraints:
11591159
try:
1160-
constraint.validate(received_value, response_type_name)
1160+
constraint.validate(received_value, response_type_name, self._runtime_config_variable_storage)
11611161
result.success(check_type, error_success)
11621162
except TestStepError as e:
11631163
e.update_context(expected_response, self.step_index)
@@ -1204,6 +1204,8 @@ def _update_placeholder_values(self, containers):
12041204

12051205
if 'constraints' in item:
12061206
for constraint, constraint_value in item['constraints'].items():
1207+
if is_variable_aware_constraint(constraint):
1208+
continue
12071209
values[idx]['constraints'][constraint] = self._config_variable_substitution(
12081210
constraint_value)
12091211

scripts/py_matter_yamltests/matter_yamltests/yaml_loader.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@
106106
'hasMasksSet': list,
107107
'hasMasksClear': list,
108108
'notValue': (type(None), bool, str, int, float, list, dict),
109-
'anyOf': list
109+
'anyOf': list,
110+
'python': str,
110111
}
111112

112113
# Note: this is not used in the loader, just provided for information in the schema tree

src/app/tests/suites/TestConstraints.yaml

+20
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ config:
1919
cluster: "Unit Testing"
2020
endpoint: 1
2121

22+
AnOctetString:
23+
type: octet_string
24+
defaultValue: hex:deafbeef
25+
2226
tests:
2327
- label: "Wait for the commissioned device to be retrieved"
2428
cluster: "DelayCommands"
@@ -52,6 +56,22 @@ tests:
5256
constraints:
5357
excludes: [0, 5]
5458

59+
- label: "Read attribute LIST With Python constraint"
60+
command: "readAttribute"
61+
attribute: "list_int8u"
62+
response:
63+
constraints:
64+
python: len(value) == 4 and len(AnOctetString) == 4
65+
66+
- label: "Read attribute LIST With multi-line Python constraint"
67+
command: "readAttribute"
68+
attribute: "list_int8u"
69+
response:
70+
constraints:
71+
python: |
72+
print("Hello from Python")
73+
return True
74+
5575
- label: "Write attribute LIST Back to Default Value"
5676
command: "writeAttribute"
5777
attribute: "list_int8u"

0 commit comments

Comments
 (0)