blob: a3f38f7514fde03a4573943dca8319e2a8d2d02c [file] [log] [blame]
#
# 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.
#
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from chip import ChipDeviceCtrl
from chip.tlv import float32
import yaml
import stringcase
import chip.interaction_model
import asyncio as asyncio
import logging
import math
from chip.yaml.errors import ParsingError, UnexpectedParsingError
from .data_model_lookup import *
import chip.yaml.format_converter as Converter
from .variable_storage import VariableStorage
from .constraints import get_constraints
_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 _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.'''
def __init__(self, label):
self._label = label
@property
def label(self):
return self._label
@abstractmethod
def run_action(self, dev_ctrl: ChipDeviceCtrl, endpoint: int, node_id: int):
pass
class InvokeAction(BaseAction):
'''Single invoke action to be executed including validation of response.'''
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.
'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.
UnexpectedParsingError: Raised if there is an unexpected parsing error.
'''
super().__init__(item['label'])
self._command_name = stringcase.pascalcase(item['command'])
self._cluster = cluster
self._request_object = None
self._expected_raw_response: dict = field(default_factory=dict)
self._expected_response_object = None
command = context.data_model_lookup.get_command(
self._cluster, self._command_name)
if command is None:
raise ParsingError(
f'Failed to find cluster:{self._cluster} Command:{self._command_name}')
command_object = command()
if (item.get('arguments')):
args = item['arguments']['values']
request_data_as_dict = Converter.convert_name_value_pair_to_dict(args)
try:
request_data = Converter.convert_yaml_type(
request_data_as_dict, type(command_object))
except ValueError:
raise ParsingError('Could not covert yaml type')
# Create a cluster object for the request from the provided YAML data.
self._request_object = command_object.FromDict(request_data)
else:
self._request_object = command_object
self._expected_raw_response = item.get('response')
if (self._request_object.response_type is not None and
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 = 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)
expected_response_data = Converter.convert_yaml_type(
expected_response_data_as_dict, expected_command)
self._expected_response_object = expected_command.FromDict(expected_response_data)
def run_action(self, dev_ctrl: ChipDeviceCtrl, endpoint: int, node_id: int):
try:
resp = asyncio.run(dev_ctrl.SendCommand(node_id, endpoint, self._request_object))
except chip.interaction_model.InteractionModelError:
if self._expected_raw_response is None:
raise
expected_status_code = self._expected_raw_response.get('error')
if expected_status_code is not None and expected_status_code != _SUCCESS_STATUS_CODE:
logger.debug('Got error response, but was expected')
else:
raise
if (self._expected_response_object is not None):
if (self._expected_response_object != resp):
logger.error(f'Expected response {self._expected_response_object} did not match '
f'actual object {resp}')
class ReadAttributeAction(BaseAction):
'''Single read attribute action to be executed including validation.'''
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.
'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.
UnexpectedParsingError: Raised if there is an unexpected parsing error.
'''
super().__init__(item['label'])
self._attribute_name = stringcase.pascalcase(item['attribute'])
self._constraints = []
self._cluster = cluster
self._cluster_object = None
self._request_object = None
self._expected_raw_response: dict = field(default_factory=dict)
self._expected_response: _ExpectedResponse = None
self._possibly_unsupported = False
self._variable_to_save = None
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 = 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} '
f'Attribute:{self._attribute_name}')
if (item.get('arguments')):
raise UnexpectedParsingError(
f'ReadAttribute should not contain arguments. {self.label}')
if self._request_object.attribute_type is None:
raise UnexpectedParsingError(
f'ReadAttribute doesnt have valid attribute_type. {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:
expected_response_value = self._expected_raw_response['value']
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 = get_constraints(constraints,
self._request_object.attribute_type.Type,
context.variable_storage)
def run_action(self, dev_ctrl: ChipDeviceCtrl, endpoint: int, node_id: int):
try:
resp = asyncio.run(dev_ctrl.ReadAttribute(node_id, [(self._request_object)]))
except chip.interaction_model.InteractionModelError:
if self._expected_raw_response is None:
raise
expected_status_code = self._expected_raw_response.get('error')
if expected_status_code is not None and expected_status_code != _SUCCESS_STATUS_CODE:
logger.debug('Got error response, but was expected')
else:
raise
if self._possibly_unsupported and not resp:
# We have found an unsupported attribute. Parsed test did specify that it might be
# unsupported, so nothing left to validate.
return
# 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._variable_to_save is not None:
self._variable_to_save.save_response(parsed_resp)
if not all([constraint.is_met(parsed_resp) for constraint in self._constraints]):
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, 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.
'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.
UnexpectedParsingError: Raised if there is an unexpected parsing error.
'''
super().__init__(item['label'])
self._attribute_name = stringcase.pascalcase(item['attribute'])
self._cluster = cluster
self._request_object = None
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} '
f'Attribute:{self._attribute_name}')
if (item.get('arguments')):
args = item['arguments']['value']
try:
request_data = Converter.convert_yaml_type(
args, attribute.attribute_type.Type)
except ValueError:
raise ParsingError('Could not covert yaml type')
# Create a cluster object for the request from the provided YAML data.
self._request_object = attribute(request_data)
else:
raise UnexpectedParsingError(f'WriteAttribute action does have arguments {self.label}')
def run_action(self, dev_ctrl: ChipDeviceCtrl, endpoint: int, node_id: int):
try:
resp = asyncio.run(
dev_ctrl.WriteAttribute(node_id, [(endpoint, self._request_object)]))
except chip.interaction_model.InteractionModelError:
if (self.expected_raw_response is not None and
self.expected_raw_response.get('error')):
logger.debug('Got error, but was expected')
else:
raise
# TODO: confirm resp give a Success value, although not all write action are expected
# to succeed, hence why this is a todo and not simply just done. Below is example of
# what success check might look like.
# asserts.assert_equal(resp[0].Status, StatusEnum.Success, 'label write must succeed')
class YamlTestParser:
'''Parses the test YAMLs and converts to a more natural Pythonic representation.
The parser also permits execution of those tests there-after.
'''
def __init__(self, yaml_path: str):
'''Constructor that parser the given a path to YAML test file.'''
with open(yaml_path, 'r') as stream:
try:
self._raw_data = yaml.safe_load(stream)
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']
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._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
# is disabled before anything else, allowing for incorrectly named commands.
if item.get('disabled'):
logger.info(f"Test is disabled, skipping {item['label']}")
continue
action = None
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')):
cluster = item.get('cluster').replace(' ', '').replace('/', '')
if item['command'] == 'writeAttribute':
action = self._attribute_write_action_factory(item, cluster)
elif item['command'] == 'readAttribute':
action = self._attribute_read_action_factory(item, cluster)
else:
action = self._invoke_action_factory(item, cluster)
if action is not None:
self._base_action_test_list.append(action)
else:
logger.warn(f"Failed to parse {item['label']}")
def _invoke_action_factory(self, item: dict, cluster: str):
'''Parse cluster command from yaml test configuration.
Args:
'item': Dictionary contains single cluster action test to be parsed
'cluster': Name of cluster action is targeting.
Returns:
InvokeAction if 'item' is a valid action to be executed.
None if 'item' was not parsed for a known reason that is not fatal.
'''
try:
return InvokeAction(item, cluster, self._context)
except ParsingError:
return None
def _attribute_read_action_factory(self, item: dict, cluster: str):
'''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.
Returns:
ReadAttributeAction if 'item' is a valid action to be executed.
None if 'item' was not parsed for a known reason that is not fatal.
'''
try:
return ReadAttributeAction(item, cluster, self._context)
except ParsingError:
return None
def _attribute_write_action_factory(self, item: dict, cluster: str):
'''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.
Returns:
WriteAttributeAction if 'item' is a valid action to be executed.
None if 'item' was not parsed for a known reason that is not fatal.
'''
try:
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._config['endpoint'], self._config['nodeId'])