From bb783639b61f28b8a1e5defda357afb2cb7e40c8 Mon Sep 17 00:00:00 2001 From: C Freeman Date: Thu, 6 Jun 2024 16:29:23 -0400 Subject: [PATCH] Python testing: Conformance support for device type conditions (#33622) * Python testing: Conformance support for device type conditions * fix type * strings * simplify exception * Restyled by isort * address review comments --------- Co-authored-by: Restyled.io --- src/python_testing/TestConformanceSupport.py | 147 ++++++++++++++----- src/python_testing/conformance_support.py | 132 ++++++++++++----- 2 files changed, 206 insertions(+), 73 deletions(-) diff --git a/src/python_testing/TestConformanceSupport.py b/src/python_testing/TestConformanceSupport.py index f71eb3ed1d531f..1405e5c8c791cc 100644 --- a/src/python_testing/TestConformanceSupport.py +++ b/src/python_testing/TestConformanceSupport.py @@ -16,15 +16,23 @@ # import xml.etree.ElementTree as ElementTree +from typing import Callable -from conformance_support import ConformanceDecision, ConformanceException, ConformanceParseParameters, parse_callable_from_xml -from matter_testing_support import MatterBaseTest, async_test_body, default_matter_test_main +from conformance_support import (ConformanceDecision, ConformanceException, ConformanceParseParameters, deprecated, disallowed, + mandatory, optional, parse_basic_callable_from_xml, parse_callable_from_xml, + parse_device_type_callable_from_xml, provisional, zigbee) +from matter_testing_support import MatterBaseTest, default_matter_test_main from mobly import asserts +def basic_test(xml: str, cls: Callable) -> None: + et = ElementTree.fromstring(xml) + xml_callable = parse_basic_callable_from_xml(et) + asserts.assert_true(isinstance(xml_callable, cls), "Unexpected class parsed from basic conformance") + + class TestConformanceSupport(MatterBaseTest): - @async_test_body - async def setup_class(self): + def setup_class(self): super().setup_class() # a small feature map self.feature_names_to_bits = {'AB': 0x01, 'CD': 0x02} @@ -46,8 +54,7 @@ async def setup_class(self): self.params = ConformanceParseParameters( feature_map=self.feature_names_to_bits, attribute_map=self.attribute_names_to_values, command_map=self.command_names_to_values) - @async_test_body - async def test_conformance_mandatory(self): + def test_conformance_mandatory(self): xml = '' et = ElementTree.fromstring(xml) xml_callable = parse_callable_from_xml(et, self.params) @@ -55,8 +62,7 @@ async def test_conformance_mandatory(self): asserts.assert_equal(xml_callable(f, [], []), ConformanceDecision.MANDATORY) asserts.assert_equal(str(xml_callable), 'M') - @async_test_body - async def test_conformance_optional(self): + def test_conformance_optional(self): xml = '' et = ElementTree.fromstring(xml) xml_callable = parse_callable_from_xml(et, self.params) @@ -64,8 +70,7 @@ async def test_conformance_optional(self): asserts.assert_equal(xml_callable(f, [], []), ConformanceDecision.OPTIONAL) asserts.assert_equal(str(xml_callable), 'O') - @async_test_body - async def test_conformance_disallowed(self): + def test_conformance_disallowed(self): xml = '' et = ElementTree.fromstring(xml) xml_callable = parse_callable_from_xml(et, self.params) @@ -80,8 +85,7 @@ async def test_conformance_disallowed(self): asserts.assert_equal(xml_callable(f, [], []), ConformanceDecision.DISALLOWED) asserts.assert_equal(str(xml_callable), 'D') - @async_test_body - async def test_conformance_provisional(self): + def test_conformance_provisional(self): xml = '' et = ElementTree.fromstring(xml) xml_callable = parse_callable_from_xml(et, self.params) @@ -89,8 +93,7 @@ async def test_conformance_provisional(self): asserts.assert_equal(xml_callable(f, [], []), ConformanceDecision.PROVISIONAL) asserts.assert_equal(str(xml_callable), 'P') - @async_test_body - async def test_conformance_zigbee(self): + def test_conformance_zigbee(self): xml = '' et = ElementTree.fromstring(xml) xml_callable = parse_callable_from_xml(et, self.params) @@ -98,8 +101,7 @@ async def test_conformance_zigbee(self): asserts.assert_equal(xml_callable(f, [], []), ConformanceDecision.NOT_APPLICABLE) asserts.assert_equal(str(xml_callable), 'Zigbee') - @async_test_body - async def test_conformance_mandatory_on_condition(self): + def test_conformance_mandatory_on_condition(self): xml = ('' '' '') @@ -151,8 +153,7 @@ async def test_conformance_mandatory_on_condition(self): # test command in optional and in boolean - this is the same as attribute essentially, so testing every permutation is overkill - @async_test_body - async def test_conformance_optional_on_condition(self): + def test_conformance_optional_on_condition(self): # single feature optional xml = ('' '' @@ -228,8 +229,7 @@ async def test_conformance_optional_on_condition(self): asserts.assert_equal(xml_callable(0x00, [], c), ConformanceDecision.NOT_APPLICABLE) asserts.assert_equal(str(xml_callable), '[cmd2]') - @async_test_body - async def test_conformance_not_term_mandatory(self): + def test_conformance_not_term_mandatory(self): # single feature not mandatory xml = ('' '' @@ -288,8 +288,7 @@ async def test_conformance_not_term_mandatory(self): asserts.assert_equal(xml_callable(0x00, a, []), ConformanceDecision.NOT_APPLICABLE) asserts.assert_equal(str(xml_callable), '!attr2') - @async_test_body - async def test_conformance_not_term_optional(self): + def test_conformance_not_term_optional(self): # single feature not optional xml = ('' '' @@ -319,8 +318,7 @@ async def test_conformance_not_term_optional(self): asserts.assert_equal(xml_callable(f, [], []), ConformanceDecision.NOT_APPLICABLE) asserts.assert_equal(str(xml_callable), '[!CD]') - @async_test_body - async def test_conformance_and_term(self): + def test_conformance_and_term(self): # and term for features only xml = ('' '' @@ -370,8 +368,7 @@ async def test_conformance_and_term(self): asserts.assert_equal(xml_callable(f, a, []), ConformanceDecision.NOT_APPLICABLE) asserts.assert_equal(str(xml_callable), 'AB & attr2') - @async_test_body - async def test_conformance_or_term(self): + def test_conformance_or_term(self): # or term feature only xml = ('' '' @@ -421,8 +418,7 @@ async def test_conformance_or_term(self): asserts.assert_equal(xml_callable(f, a, []), ConformanceDecision.NOT_APPLICABLE) asserts.assert_equal(str(xml_callable), 'AB | attr2') - @async_test_body - async def test_conformance_and_term_with_not(self): + def test_conformance_and_term_with_not(self): # and term with not xml = ('' '' @@ -441,8 +437,7 @@ async def test_conformance_and_term_with_not(self): asserts.assert_equal(xml_callable(f, [], []), ConformanceDecision.NOT_APPLICABLE) asserts.assert_equal(str(xml_callable), '[!AB & CD]') - @async_test_body - async def test_conformance_or_term_with_not(self): + def test_conformance_or_term_with_not(self): # or term with not on second feature xml = ('' '' @@ -479,8 +474,7 @@ async def test_conformance_or_term_with_not(self): asserts.assert_equal(xml_callable(f, [], []), ConformanceDecision.NOT_APPLICABLE) asserts.assert_equal(str(xml_callable), '[!(AB | CD)]') - @async_test_body - async def test_conformance_and_term_with_three_terms(self): + def test_conformance_and_term_with_three_terms(self): # and term with three features xml = ('' '' @@ -519,8 +513,7 @@ async def test_conformance_and_term_with_three_terms(self): asserts.assert_equal(xml_callable(f, a, c), ConformanceDecision.NOT_APPLICABLE) asserts.assert_equal(str(xml_callable), '[AB & attr1 & cmd1]') - @async_test_body - async def test_conformance_or_term_with_three_terms(self): + def test_conformance_or_term_with_three_terms(self): # or term with three features xml = ('' '' @@ -671,6 +664,92 @@ def test_conformance_greater(self): except ConformanceException: pass + def test_basic_conformance(self): + basic_test('', mandatory) + basic_test('', optional) + basic_test('', disallowed) + basic_test('', deprecated) + basic_test('', provisional) + basic_test('', zigbee) + + # feature is not basic so we should get an exception + xml = '' + et = ElementTree.fromstring(xml) + try: + parse_basic_callable_from_xml(et) + asserts.fail("Unexpected success parsing non-basic conformance") + except ConformanceException: + pass + + # mandatory tag is basic, but this one is a wrapper, so we should get a TypeError + xml = ('' + '' + '' + '' + '' + '' + '' + '') + et = ElementTree.fromstring(xml) + try: + parse_basic_callable_from_xml(et) + asserts.fail("Unexpected success parsing mandatory wrapper") + except ConformanceException: + pass + + def test_device_type_conformance(self): + msg = "Unexpected conformance returned for device type" + xml = ('' + '' + '') + et = ElementTree.fromstring(xml) + xml_callable = parse_device_type_callable_from_xml(et) + asserts.assert_equal(str(xml_callable), 'Zigbee', msg) + asserts.assert_equal(xml_callable(0, [], []), ConformanceDecision.NOT_APPLICABLE, msg) + + xml = ('' + '' + '') + et = ElementTree.fromstring(xml) + xml_callable = parse_device_type_callable_from_xml(et) + # expect no exception here + asserts.assert_equal(str(xml_callable), '[Zigbee]', msg) + asserts.assert_equal(xml_callable(0, [], []), ConformanceDecision.NOT_APPLICABLE, msg) + + # otherwise conforms are allowed + xml = ('' + '' + '' + '') + et = ElementTree.fromstring(xml) + xml_callable = parse_device_type_callable_from_xml(et) + # expect no exception here + asserts.assert_equal(str(xml_callable), 'Zigbee, P', msg) + asserts.assert_equal(xml_callable(0, [], []), ConformanceDecision.PROVISIONAL, msg) + + # Device type conditions or features don't correspond to anything in the spec, so the XML takes a best + # guess as to what they are. We should be able to parse features, conditions, attributes as the same + # thing. + # TODO: allow querying conformance for conditional device features + # TODO: adjust conformance call function to accept a list of features and evaluate based on that + xml = ('' + '' + '') + et = ElementTree.fromstring(xml) + xml_callable = parse_device_type_callable_from_xml(et) + asserts.assert_equal(str(xml_callable), 'CD', msg) + # Device features are always optional (at least for now), even though we didn't pass this feature in + asserts.assert_equal(xml_callable(0, [], []), ConformanceDecision.OPTIONAL) + + xml = ('' + '' + '' + '') + et = ElementTree.fromstring(xml) + xml_callable = parse_device_type_callable_from_xml(et) + asserts.assert_equal(str(xml_callable), 'CD, testy', msg) + asserts.assert_equal(xml_callable(0, [], []), ConformanceDecision.OPTIONAL) + if __name__ == "__main__": default_matter_test_main() diff --git a/src/python_testing/conformance_support.py b/src/python_testing/conformance_support.py index 025c1afa678995..c9bdb5830c884b 100644 --- a/src/python_testing/conformance_support.py +++ b/src/python_testing/conformance_support.py @@ -39,6 +39,7 @@ COMMAND_TAG = 'command' CONDITION_TAG = 'condition' LITERAL_TAG = 'literal' +ZIGBEE_CONDITION = 'zigbee' class ConformanceException(Exception): @@ -137,6 +138,16 @@ def __str__(self): return str(self.value) +# Conformance options that apply regardless of the element set of the cluster or device +BASIC_CONFORMANCE: dict[str, Callable] = { + MANDATORY_CONFORM: mandatory(), + OPTIONAL_CONFORM: optional(), + PROVISIONAL_CONFORM: provisional(), + DEPRECATE_CONFORM: deprecated(), + DISALLOW_CONFORM: disallowed() +} + + class feature: def __init__(self, requiredFeature: uint, code: str): self.requiredFeature = requiredFeature @@ -148,7 +159,20 @@ def __call__(self, feature_map: uint, attribute_list: list[uint], all_command_li return ConformanceDecision.NOT_APPLICABLE def __str__(self): - return f'{self.code}' + return self.code + + +class device_feature: + ''' This is different than element feature because device types use "features" that aren't reported anywhere''' + + def __init__(self, feature: str): + self.feature = feature + + def __call__(self, feature_map: uint = 0, attribute_list: list[uint] = [], all_command_list: list[uint] = []) -> ConformanceDecision: + return ConformanceDecision.OPTIONAL + + def __str__(self): + return self.feature class attribute: @@ -162,7 +186,7 @@ def __call__(self, feature_map: uint, attribute_list: list[uint], all_command_li return ConformanceDecision.NOT_APPLICABLE def __str__(self): - return f'{self.name}' + return self.name class command: @@ -176,7 +200,7 @@ def __call__(self, feature_map: uint, attribute_list: list[uint], all_command_li return ConformanceDecision.NOT_APPLICABLE def __str__(self): - return f'{self.name}' + return self.name def strip_outer_parentheses(inner: str) -> str: @@ -222,8 +246,11 @@ def __call__(self, feature_map: uint, attribute_list: list[uint], all_command_li # not operations also can't be used with things that are optional # ie, ![AB] doesn't make sense, nor does !O decision = self.op(feature_map, attribute_list, all_command_list) - if decision == ConformanceDecision.OPTIONAL or decision == ConformanceDecision.DISALLOWED or decision == ConformanceDecision.PROVISIONAL: + if decision == ConformanceDecision.DISALLOWED or decision == ConformanceDecision.PROVISIONAL: raise ConformanceException('NOT operation on optional or disallowed item') + # Features in device types degrade to optional so a not operation here is still optional because we don't have any way to verify the features since they're not exposed anywhere + elif decision == ConformanceDecision.OPTIONAL: + return ConformanceDecision.OPTIONAL elif decision == ConformanceDecision.NOT_APPLICABLE: return ConformanceDecision.MANDATORY elif decision == ConformanceDecision.MANDATORY: @@ -325,48 +352,23 @@ def __str__(self): return ', '.join(op_strs) -def parse_callable_from_xml(element: ElementTree.Element, params: ConformanceParseParameters) -> Callable: - if len(list(element)) == 0: - # no subchildren here, so this can only be mandatory, optional, provisional, deprecated, disallowed, feature or attribute - if element.tag == MANDATORY_CONFORM: - return mandatory() - elif element.tag == OPTIONAL_CONFORM: - return optional() - elif element.tag == PROVISIONAL_CONFORM: - return provisional() - elif element.tag == DEPRECATE_CONFORM: - return deprecated() - elif element.tag == DISALLOW_CONFORM: - return disallowed() - elif element.tag == FEATURE_TAG: - try: - return feature(params.feature_map[element.get('name')], element.get('name')) - except KeyError: - raise ConformanceException(f'Conformance specifies feature not in feature table: {element.get("name")}') - elif element.tag == ATTRIBUTE_TAG: - # Some command conformance tags are marked as attribute, so if this key isn't in attribute, try command - name = element.get('name') - if name in params.attribute_map: - return attribute(params.attribute_map[name], name) - elif name in params.command_map: - return command(params.command_map[name], name) - else: - raise ConformanceException(f'Conformance specifies attribute or command not in table: {name}') - elif element.tag == COMMAND_TAG: - return command(params.command_map[element.get('name')], element.get('name')) - elif element.tag == CONDITION_TAG and element.get('name').lower() == 'zigbee': +def parse_basic_callable_from_xml(element: ElementTree.Element) -> Callable: + if list(element): + raise ConformanceException("parse_basic_callable_from_xml called for XML element with children") + # This will throw a key error if this is not a basic element key. + try: + return BASIC_CONFORMANCE[element.tag] + except KeyError: + if element.tag == CONDITION_TAG and element.get('name').lower() == ZIGBEE_CONDITION: return zigbee() elif element.tag == LITERAL_TAG: return literal(element.get('value')) else: raise ConformanceException( - f'Unexpected xml conformance element with no children {str(element.tag)} {str(element.attrib)}') + f'parse_basic_callable_from_xml called for unknown element {str(element.tag)} {str(element.attrib)}') - # First build the list, then create the callable for this element - ops = [] - for sub in element: - ops.append(parse_callable_from_xml(sub, params)) +def parse_wrapper_callable_from_xml(element: ElementTree.Element, ops: list[Callable]) -> Callable: # optional can be a wrapper as well as a standalone # This can be any of the boolean operations, optional or otherwise if element.tag == OPTIONAL_CONFORM: @@ -393,3 +395,55 @@ def parse_callable_from_xml(element: ElementTree.Element, params: ConformancePar return greater_operation(ops[0], ops[1]) else: raise ConformanceException(f'Unexpected conformance tag with children {element}') + + +def parse_device_type_callable_from_xml(element: ElementTree.Element) -> Callable: + ''' Only allows basic, or wrappers over things that degrade to basic.''' + if not list(element): + try: + return parse_basic_callable_from_xml(element) + # For device types ONLY, there are conformances called "attributes" that are essentially just placeholders for conditions in the device library. + # For example, temperature controlled cabinet has conditions called "heating" and "cooling". The cluster conditions are dependent on them, but they're not + # actually exposed anywhere ON the device other than through the presence of the cluster. So for now, treat any attribute conditions that are cluster conditions + # as just optional, because it's optional to implement any device type feature. + # Device types also have some marked as "condition" that are similarly optional + except ConformanceException: + if element.tag == ATTRIBUTE_TAG or element.tag == CONDITION_TAG or element.tag == FEATURE_TAG: + return device_feature(element.attrib['name']) + raise + + ops = [parse_device_type_callable_from_xml(sub) for sub in element] + return parse_wrapper_callable_from_xml(element, ops) + + +def parse_callable_from_xml(element: ElementTree.Element, params: ConformanceParseParameters) -> Callable: + if not list(element): + try: + return parse_basic_callable_from_xml(element) + except ConformanceException: + # If we get an exception here, it wasn't a basic type, so move on and check if its + # something else. + pass + if element.tag == FEATURE_TAG: + try: + return feature(params.feature_map[element.get('name')], element.get('name')) + except KeyError: + raise ConformanceException(f'Conformance specifies feature not in feature table: {element.get("name")}') + elif element.tag == ATTRIBUTE_TAG: + # Some command conformance tags are marked as attribute, so if this key isn't in attribute, try command + name = element.get('name') + if name in params.attribute_map: + return attribute(params.attribute_map[name], name) + elif name in params.command_map: + return command(params.command_map[name], name) + else: + raise ConformanceException(f'Conformance specifies attribute or command not in table: {name}') + elif element.tag == COMMAND_TAG: + return command(params.command_map[element.get('name')], element.get('name')) + else: + raise ConformanceException( + f'Unexpected xml conformance element with no children {str(element.tag)} {str(element.attrib)}') + + # First build the list, then create the callable for this element + ops = [parse_callable_from_xml(sub, params) for sub in element] + return parse_wrapper_callable_from_xml(element, ops)