From ab8604b7268920d95362761922ffa2ad3e0e827b Mon Sep 17 00:00:00 2001 From: cecille Date: Mon, 27 May 2024 11:26:01 -0400 Subject: [PATCH 1/6] Python testing: Conformance support for device type conditions --- src/python_testing/TestConformanceSupport.py | 146 ++++++++++++++----- src/python_testing/conformance_support.py | 130 ++++++++++++----- 2 files changed, 206 insertions(+), 70 deletions(-) diff --git a/src/python_testing/TestConformanceSupport.py b/src/python_testing/TestConformanceSupport.py index f71eb3ed1d531f..b5a92d63766d87 100644 --- a/src/python_testing/TestConformanceSupport.py +++ b/src/python_testing/TestConformanceSupport.py @@ -17,14 +17,21 @@ import xml.etree.ElementTree as ElementTree -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 +53,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 +61,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 +69,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 +84,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 +92,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 +100,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 +152,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 +228,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 +287,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 +317,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 +367,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 +417,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 +436,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 +473,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 +512,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 +663,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..945dd155927e7a 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 @@ -151,6 +162,19 @@ def __str__(self): return f'{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 f'{self.feature}' + + class attribute: def __init__(self, requiredAttribute: uint, name: str): self.requiredAttribute = requiredAttribute @@ -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,59 @@ 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 as e: + if element.tag == ATTRIBUTE_TAG or element.tag == CONDITION_TAG or element.tag == FEATURE_TAG: + return device_feature(element.attrib['name']) + raise e + + ops = [] + for sub in element: + ops.append(parse_device_type_callable_from_xml(sub)) + + 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: + 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 = [] + for sub in element: + ops.append(parse_callable_from_xml(sub, params)) + + return parse_wrapper_callable_from_xml(element, ops) From cda027da8a6b14e30ac7af897f64ae47f753a8f2 Mon Sep 17 00:00:00 2001 From: cecille Date: Mon, 27 May 2024 15:36:38 -0400 Subject: [PATCH 2/6] fix type --- src/python_testing/TestConformanceSupport.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/python_testing/TestConformanceSupport.py b/src/python_testing/TestConformanceSupport.py index b5a92d63766d87..4f3ba58acea4ba 100644 --- a/src/python_testing/TestConformanceSupport.py +++ b/src/python_testing/TestConformanceSupport.py @@ -15,6 +15,7 @@ # limitations under the License. # +from typing import Callable import xml.etree.ElementTree as ElementTree from conformance_support import (ConformanceDecision, ConformanceException, ConformanceParseParameters, deprecated, disallowed, @@ -24,7 +25,7 @@ from mobly import asserts -def basic_test(xml: str, cls: callable) -> None: +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") From d78f870194510b9310b889c168a2eb913259af11 Mon Sep 17 00:00:00 2001 From: cecille Date: Mon, 27 May 2024 15:41:15 -0400 Subject: [PATCH 3/6] strings --- src/python_testing/conformance_support.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/python_testing/conformance_support.py b/src/python_testing/conformance_support.py index 945dd155927e7a..0a499ae049cf2d 100644 --- a/src/python_testing/conformance_support.py +++ b/src/python_testing/conformance_support.py @@ -159,7 +159,7 @@ 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: @@ -172,7 +172,7 @@ def __call__(self, feature_map: uint = 0, attribute_list: list[uint] = [], all_c return ConformanceDecision.OPTIONAL def __str__(self): - return f'{self.feature}' + return self.feature class attribute: @@ -186,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: @@ -200,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: From 8fca720d953ab92626175f28a5a30b50e60c0d0f Mon Sep 17 00:00:00 2001 From: cecille Date: Mon, 27 May 2024 15:43:11 -0400 Subject: [PATCH 4/6] simplify exception --- src/python_testing/conformance_support.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/python_testing/conformance_support.py b/src/python_testing/conformance_support.py index 0a499ae049cf2d..d06fcb3691d8b5 100644 --- a/src/python_testing/conformance_support.py +++ b/src/python_testing/conformance_support.py @@ -407,10 +407,10 @@ def parse_device_type_callable_from_xml(element: ElementTree.Element) -> Callabl # 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 as e: + except ConformanceException: if element.tag == ATTRIBUTE_TAG or element.tag == CONDITION_TAG or element.tag == FEATURE_TAG: return device_feature(element.attrib['name']) - raise e + raise ops = [] for sub in element: From bb4e463ebee605949abfe61f47e4787e8757cfb0 Mon Sep 17 00:00:00 2001 From: "Restyled.io" Date: Tue, 28 May 2024 16:04:06 +0000 Subject: [PATCH 5/6] Restyled by isort --- src/python_testing/TestConformanceSupport.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/python_testing/TestConformanceSupport.py b/src/python_testing/TestConformanceSupport.py index 4f3ba58acea4ba..1405e5c8c791cc 100644 --- a/src/python_testing/TestConformanceSupport.py +++ b/src/python_testing/TestConformanceSupport.py @@ -15,8 +15,8 @@ # limitations under the License. # -from typing import Callable import xml.etree.ElementTree as ElementTree +from typing import Callable from conformance_support import (ConformanceDecision, ConformanceException, ConformanceParseParameters, deprecated, disallowed, mandatory, optional, parse_basic_callable_from_xml, parse_callable_from_xml, From 60d6db753e416c0d7b7cca55146775c738e8d792 Mon Sep 17 00:00:00 2001 From: Cecille Freeman Date: Tue, 4 Jun 2024 13:59:06 -0400 Subject: [PATCH 6/6] address review comments --- src/python_testing/conformance_support.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/python_testing/conformance_support.py b/src/python_testing/conformance_support.py index d06fcb3691d8b5..c9bdb5830c884b 100644 --- a/src/python_testing/conformance_support.py +++ b/src/python_testing/conformance_support.py @@ -412,10 +412,7 @@ def parse_device_type_callable_from_xml(element: ElementTree.Element) -> Callabl return device_feature(element.attrib['name']) raise - ops = [] - for sub in element: - ops.append(parse_device_type_callable_from_xml(sub)) - + ops = [parse_device_type_callable_from_xml(sub) for sub in element] return parse_wrapper_callable_from_xml(element, ops) @@ -424,6 +421,8 @@ def parse_callable_from_xml(element: ElementTree.Element, params: ConformancePar 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: @@ -446,8 +445,5 @@ def parse_callable_from_xml(element: ElementTree.Element, params: ConformancePar 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 = [] - for sub in element: - ops.append(parse_callable_from_xml(sub, params)) - + ops = [parse_callable_from_xml(sub, params) for sub in element] return parse_wrapper_callable_from_xml(element, ops)