Adding ability to process config variables, saveAs and constraints to yaml python parser (#23599)
* Adding ability to process config variables, saveAs and constraints
* Rename class
* Refactor save as a little
* Address PR comments
* remove unneeded change in format_converter.py
* Address PR comment
* Update comment in docstring
* Update build file to renamed file
* Address PR comment
diff --git a/src/controller/python/BUILD.gn b/src/controller/python/BUILD.gn
index c892038..9108c12 100644
--- a/src/controller/python/BUILD.gn
+++ b/src/controller/python/BUILD.gn
@@ -234,6 +234,7 @@
"chip/yaml/errors.py",
"chip/yaml/format_converter.py",
"chip/yaml/parser.py",
+ "chip/yaml/variable_storage.py",
]
if (chip_controller) {
diff --git a/src/controller/python/chip/yaml/parser.py b/src/controller/python/chip/yaml/parser.py
index 67fc363..8fc5cd5 100644
--- a/src/controller/python/chip/yaml/parser.py
+++ b/src/controller/python/chip/yaml/parser.py
@@ -16,9 +16,9 @@
#
from abc import ABC, abstractmethod
-from dataclasses import field
-import typing
+from dataclasses import dataclass, field
from chip import ChipDeviceCtrl
+from chip.clusters.Types import NullValue
from chip.tlv import float32
import yaml
import stringcase
@@ -29,11 +29,174 @@
from chip.yaml.errors import ParsingError, UnexpectedParsingError
from .data_model_lookup import *
import chip.yaml.format_converter as Converter
+from .variable_storage import VariableStorage
_SUCCESS_STATUS_CODE = "SUCCESS"
+_NODE_ID_DEFAULT = 0x12345
+_ENDPOINT_DETAULT = '' # TODO why is this an empty string
+_CLUSTER_DEFAULT = ''
+_TIMEOUT_DEFAULT = 90
logger = logging.getLogger('YamlParser')
+@dataclass
+class _ExecutionContext:
+ ''' Objects that is commonly passed around this file that are vital to test execution.'''
+ # Data model lookup to get python attribute, cluster, command object.
+ data_model_lookup: DataModelLookup = None
+ # Where various test action response are stored and loaded from.
+ variable_storage: VariableStorage = None
+ # Top level configuration values for a yaml test.
+ config_values: dict = None
+
+
+class _ConstraintValue:
+ '''Constraints that are numeric primitive data types'''
+
+ def __init__(self, value, field_type, context: _ExecutionContext):
+ self._variable_storage = context.variable_storage
+ # When not none _indirect_value_key is binding a name to the constraint value, and the
+ # actual value can only be looked-up dynamically, which is why this is a key name.
+ self._indirect_value_key = None
+ self._value = None
+
+ if value is None:
+ # Default values set above is all we need here.
+ return
+
+ if isinstance(value, str) and self._variable_storage.is_key_saved(value):
+ self._indirect_value_key = value
+ else:
+ self._value = Converter.convert_yaml_type(
+ value, field_type)
+
+ def get_value(self):
+ '''Gets the current value of the constraint.
+
+ This method accounts for getting the runtime saved value from DUT previous responses.
+ '''
+ if self._indirect_value_key:
+ return self._variable_storage.load(self._indirect_value_key)
+ return self._value
+
+
+class _Constraints:
+ def __init__(self, constraints: dict, field_type, context: _ExecutionContext):
+ self._variable_storage = context.variable_storage
+ self._has_value = constraints.get('hasValue')
+ self._type = constraints.get('type')
+ self._starts_with = constraints.get('startsWith')
+ self._ends_with = constraints.get('endsWith')
+ self._is_upper_case = constraints.get('isUpperCase')
+ self._is_lower_case = constraints.get('isLowerCase')
+ self._min_value = _ConstraintValue(constraints.get('minValue'), field_type,
+ context)
+ self._max_value = _ConstraintValue(constraints.get('maxValue'), field_type,
+ context)
+ self._contains = constraints.get('contains')
+ self._excludes = constraints.get('excludes')
+ self._has_masks_set = constraints.get('hasMasksSet')
+ self._has_masks_clear = constraints.get('hasMasksClear')
+ self._not_value = _ConstraintValue(constraints.get('notValue'), field_type,
+ context)
+
+ def are_constrains_met(self, response) -> bool:
+ return_value = True
+
+ if self._has_value:
+ logger.warn(f'HasValue constraint currently not implemented, forcing failure')
+ return_value = False
+
+ if self._type:
+ logger.warn(f'Type constraint currently not implemented, forcing failure')
+ return_value = False
+
+ if self._starts_with and not response.startswith(self._starts_with):
+ return_value = False
+
+ if self._ends_with and not response.endswith(self._ends_with):
+ return_value = False
+
+ if self._is_upper_case and not response.isupper():
+ return_value = False
+
+ if self._is_lower_case and not response.islower():
+ return_value = False
+
+ min_value = self._min_value.get_value()
+ if response is not NullValue and min_value and response < min_value:
+ return_value = False
+
+ max_value = self._max_value.get_value()
+ if response is not NullValue and max_value and response > max_value:
+ return_value = False
+
+ if self._contains and not set(self._contains).issubset(response):
+ return_value = False
+
+ if self._excludes and not set(self._excludes).isdisjoint(response):
+ return_value = False
+
+ if self._has_masks_set:
+ for mask in self._has_masks_set:
+ if (response & mask) != mask:
+ return_value = False
+
+ if self._has_masks_clear:
+ for mask in self._has_masks_clear:
+ if (response & mask) != 0:
+ return_value = False
+
+ not_value = self._not_value.get_value()
+ if not_value and response == not_value:
+ return_value = False
+
+ return return_value
+
+
+class _VariableToSave:
+ def __init__(self, variable_name: str, variable_storage: VariableStorage):
+ self._variable_name = variable_name
+ self._variable_storage = variable_storage
+ self._variable_storage.save(self._variable_name, None)
+
+ def save_response(self, value):
+ self._variable_storage.save(self._variable_name, value)
+
+
+class _ExpectedResponse:
+ def __init__(self, value, response_type, context: _ExecutionContext):
+ self._load_expected_response_in_verify = None
+ self._expected_response_type = response_type
+ self._expected_response = None
+ self._variable_storage = context.variable_storage
+ if isinstance(value, str) and self._variable_storage.is_key_saved(value):
+ self._load_expected_response_in_verify = value
+ else:
+ self._expected_response = Converter.convert_yaml_type(
+ value, response_type, use_from_dict=True)
+
+ def verify(self, response):
+ if (self._expected_response_type is None):
+ return True
+
+ if self._load_expected_response_in_verify is not None:
+ self._expected_response = self._variable_storage.load(
+ self._load_expected_response_in_verify)
+
+ if isinstance(self._expected_response_type, float32):
+ if not math.isclose(self._expected_response, response, rel_tol=1e-6):
+ logger.error(f"Expected response {self._expected_response} didn't match "
+ f"actual object {response}")
+ return False
+
+ if (self._expected_response != response):
+ logger.error(f"Expected response {self._expected_response} didn't match "
+ f"actual object {response}")
+ return False
+ return True
+
+
class BaseAction(ABC):
'''Interface for a single yaml action that is to be executed.'''
@@ -52,13 +215,14 @@
class InvokeAction(BaseAction):
'''Single invoke action to be executed including validation of response.'''
- def __init__(self, item: dict, cluster: str, data_model_lookup: DataModelLookup):
+ def __init__(self, item: dict, cluster: str, context: _ExecutionContext):
'''Parse cluster invoke from yaml test configuration.
Args:
'item': Dictionary containing single invoke to be parsed.
'cluster': Name of cluster which to invoke action is targeting.
- 'data_model_lookup': Data model lookup to get attribute object.
+ 'context': Contains test-wide common objects such as DataModelLookup instance, storage
+ for device responses and top level test configurations variable.
Raises:
ParsingError: Raised if there is a benign error, and there is currently no
action to perform for this write attribute.
@@ -71,7 +235,8 @@
self._expected_raw_response: dict = field(default_factory=dict)
self._expected_response_object = None
- command = data_model_lookup.get_command(self._cluster, self._command_name)
+ command = context.data_model_lookup.get_command(
+ self._cluster, self._command_name)
if command is None:
raise ParsingError(
@@ -100,7 +265,8 @@
self._expected_raw_response is not None and
self._expected_raw_response.get('values')):
response_type = stringcase.pascalcase(self._request_object.response_type)
- expected_command = data_model_lookup.get_command(self._cluster, response_type)
+ expected_command = context.data_model_lookup.get_command(self._cluster,
+ response_type)
expected_response_args = self._expected_raw_response['values']
expected_response_data_as_dict = Converter.convert_name_value_pair_to_dict(
expected_response_args)
@@ -130,13 +296,14 @@
class ReadAttributeAction(BaseAction):
'''Single read attribute action to be executed including validation.'''
- def __init__(self, item: dict, cluster: str, data_model_lookup: DataModelLookup):
+ def __init__(self, item: dict, cluster: str, context: _ExecutionContext):
'''Parse read attribute action from yaml test configuration.
Args:
'item': Dictionary contains single read attribute action to be parsed.
'cluster': Name of cluster read attribute action is targeting.
- 'data_model_lookup': Data model lookup to get attribute object.
+ 'context': Contains test-wide common objects such as DataModelLookup instance, storage
+ for device responses and top level test configurations variable.
Raises:
ParsingError: Raised if there is a benign error, and there is currently no
action to perform for this read attribute.
@@ -144,19 +311,22 @@
'''
super().__init__(item['label'])
self._attribute_name = stringcase.pascalcase(item['attribute'])
+ self._constraints = None
self._cluster = cluster
self._cluster_object = None
self._request_object = None
self._expected_raw_response: dict = field(default_factory=dict)
- self._expected_response_object: None = None
+ self._expected_response: _ExpectedResponse = None
self._possibly_unsupported = False
+ self._variable_to_save = None
- self._cluster_object = data_model_lookup.get_cluster(self._cluster)
+ self._cluster_object = context.data_model_lookup.get_cluster(self._cluster)
if self._cluster_object is None:
raise UnexpectedParsingError(
f'ReadAttribute failed to find cluster object:{self._cluster}')
- self._request_object = data_model_lookup.get_attribute(self._cluster, self._attribute_name)
+ self._request_object = context.data_model_lookup.get_attribute(
+ self._cluster, self._attribute_name)
if self._request_object is None:
raise ParsingError(
f'ReadAttribute failed to find cluster:{self._cluster} '
@@ -170,18 +340,31 @@
raise UnexpectedParsingError(
f'ReadAttribute doesnt have valid attribute_type. {self.label}')
- self._expected_raw_response = item.get('response')
- if (self._expected_raw_response is None):
- raise UnexpectedParsingError(f'ReadAttribute missing expected response. {self.label}')
-
if 'optional' in item:
self._possibly_unsupported = True
+ self._expected_raw_response = item.get('response')
+ if (self._expected_raw_response is None):
+ # TODO actually if response is missing it typically means that we need to confirm
+ # that we got a successful response. This will be implemented later to consider all
+ # possible corner cases around that (if there are corner cases).
+ raise UnexpectedParsingError(f'ReadAttribute missing expected response. {self.label}')
+
+ variable_name = self._expected_raw_response.get('saveAs')
+ if variable_name:
+ self._variable_to_save = _VariableToSave(variable_name, context.variable_storage)
+
if 'value' in self._expected_raw_response:
- self._expected_response_object = self._request_object.attribute_type.Type
expected_response_value = self._expected_raw_response['value']
- self._expected_response_data = Converter.convert_yaml_type(
- expected_response_value, self._expected_response_object, use_from_dict=True)
+ self._expected_response = _ExpectedResponse(expected_response_value,
+ self._request_object.attribute_type.Type,
+ context)
+
+ constraints = self._expected_raw_response.get('constraints')
+ if constraints:
+ self._constraints = _Constraints(constraints,
+ self._request_object.attribute_type.Type,
+ context)
def run_action(self, dev_ctrl: ChipDeviceCtrl, endpoint: int, node_id: int):
try:
@@ -201,29 +384,32 @@
# unsupported, so nothing left to validate.
return
- # TODO: There is likely an issue here with Optional fields since None
- if (self._expected_response_object is not None):
- parsed_resp = resp[endpoint][self._cluster_object][self._request_object]
+ # TODO Currently there are no checks that this indexing won't fail. Need to add some
+ # initial validity checks. Coming soon an a future PR.
+ parsed_resp = resp[endpoint][self._cluster_object][self._request_object]
- if (self._expected_response_data != parsed_resp):
- # TODO: It is debatable if this is the right thing to be doing here. This might
- # need a follow up cleanup.
- if (self._expected_response_object != float32 or
- not math.isclose(self._expected_response_data, parsed_resp, rel_tol=1e-6)):
- logger.error(f'Expected response {self._expected_response_data} didnt match '
- f'actual object {parsed_resp}')
+ if self._variable_to_save is not None:
+ self._variable_to_save.save_response(parsed_resp)
+
+ if self._constraints and not self._constraints.are_constrains_met(parsed_resp):
+ logger.error(f'Constraints check failed')
+ # TODO how should we fail the test here?
+
+ if self._expected_response is not None:
+ self._expected_response.verify(parsed_resp)
class WriteAttributeAction(BaseAction):
'''Single write attribute action to be executed including validation.'''
- def __init__(self, item: dict, cluster: str, data_model_lookup: DataModelLookup):
+ def __init__(self, item: dict, cluster: str, context: _ExecutionContext):
'''Parse write attribute action from yaml test configuration.
Args:
'item': Dictionary contains single write attribute action to be parsed.
'cluster': Name of cluster write attribute action is targeting.
- 'data_model_lookup': Data model lookup to get attribute object.
+ 'context': Contains test-wide common objects such as DataModelLookup instance, storage
+ for device responses and top level test configurations variable.
Raises:
ParsingError: Raised if there is a benign error, and there is currently no
action to perform for this write attribute.
@@ -234,7 +420,8 @@
self._cluster = cluster
self._request_object = None
- attribute = data_model_lookup.get_attribute(self._cluster, self._attribute_name)
+ attribute = context.data_model_lookup.get_attribute(
+ self._cluster, self._attribute_name)
if attribute is None:
raise ParsingError(
f'WriteAttribute failed to find cluster:{self._cluster} '
@@ -284,14 +471,25 @@
except yaml.YAMLError as exc:
raise exc
+ if 'name' not in self._raw_data:
+ raise UnexpectedParsingError("YAML expected to have 'name'")
self._name = self._raw_data['name']
- self._node_id = self._raw_data['config']['nodeId']
- self._cluster = self._raw_data['config'].get('cluster')
- if self._cluster:
- self._cluster = self._cluster.replace(' ', '')
- self._endpoint = self._raw_data['config']['endpoint']
+
+ if 'config' not in self._raw_data:
+ raise UnexpectedParsingError("YAML expected to have 'config'")
+ self._config = self._raw_data['config']
+
+ self._config.setdefault('nodeId', _NODE_ID_DEFAULT)
+ self._config.setdefault('endpoint', _ENDPOINT_DETAULT)
+ self._config.setdefault('cluster', _CLUSTER_DEFAULT)
+ # TODO timeout is currently not used
+ self._config.setdefault('timeout', _TIMEOUT_DEFAULT)
+
+ self._config['cluster'] = self._config['cluster'].replace(' ', '').replace('/', '')
self._base_action_test_list = []
- self._data_model_lookup = PreDefinedDataModelLookup()
+ self._context = _ExecutionContext(data_model_lookup=PreDefinedDataModelLookup(),
+ variable_storage=VariableStorage(),
+ config_values=self._config)
for item in self._raw_data['tests']:
# This currently behaves differently than the c++ version. We are evaluating if test
@@ -301,7 +499,7 @@
continue
action = None
- cluster = self._cluster
+ cluster = self._config['cluster']
# Some of the tests contain 'cluster over-rides' that refer to a different
# cluster than that specified in 'config'.
if (item.get('cluster')):
@@ -329,7 +527,7 @@
None if 'item' was not parsed for a known reason that is not fatal.
'''
try:
- return InvokeAction(item, cluster, self._data_model_lookup)
+ return InvokeAction(item, cluster, self._context)
except ParsingError:
return None
@@ -344,7 +542,7 @@
None if 'item' was not parsed for a known reason that is not fatal.
'''
try:
- return ReadAttributeAction(item, cluster, self._data_model_lookup)
+ return ReadAttributeAction(item, cluster, self._context)
except ParsingError:
return None
@@ -359,13 +557,14 @@
None if 'item' was not parsed for a known reason that is not fatal.
'''
try:
- return WriteAttributeAction(item, cluster, self._data_model_lookup)
+ return WriteAttributeAction(item, cluster, self._context)
except ParsingError:
return None
def execute_tests(self, dev_ctrl: ChipDeviceCtrl):
'''Executes parsed YAML tests.'''
+ self._context.variable_storage.clear()
for idx, action in enumerate(self._base_action_test_list):
logger.info(f'test: {idx} -- Executing{action.label}')
- action.run_action(dev_ctrl, self._endpoint, self._node_id)
+ action.run_action(dev_ctrl, self._config['endpoint'], self._config['nodeId'])
diff --git a/src/controller/python/chip/yaml/variable_storage.py b/src/controller/python/chip/yaml/variable_storage.py
new file mode 100644
index 0000000..d62a7dd
--- /dev/null
+++ b/src/controller/python/chip/yaml/variable_storage.py
@@ -0,0 +1,37 @@
+#
+# Copyright (c) 2022 Project CHIP Authors
+# All rights reserved.
+#
+# 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.
+#
+
+class VariableStorage:
+ '''Stores key value pairs.
+
+ This is a code readability convience object for saving/loading values.
+ '''
+
+ def __init__(self):
+ self._saved_list = {}
+
+ def save(self, key, value):
+ self._saved_list[key] = value
+
+ def load(self, key):
+ return self._saved_list.get(key)
+
+ def is_key_saved(self, key) -> bool:
+ return key in self._saved_list
+
+ def clear(self):
+ self._saved_list.clear()