Skip to content

Commit e796147

Browse files
authored
Extract decorator utilities from matter_testing.py into separate module (#37639)
* initial decouple * update imports * isort * add decorators.py to BUILD.gn * use correct class * use conditional import and add missing aliases * rely on duck typing * add docstrings * add missing types * resolve review comments * fix restyled issue
1 parent 3f1fb93 commit e796147

File tree

3 files changed

+376
-255
lines changed

3 files changed

+376
-255
lines changed

src/python_testing/matter_testing_infrastructure/BUILD.gn

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ pw_python_package("chip-testing-module") {
4141
"chip/testing/commissioning.py",
4242
"chip/testing/conformance.py",
4343
"chip/testing/conversions.py",
44+
"chip/testing/decorators.py",
4445
"chip/testing/global_attribute_ids.py",
4546
"chip/testing/matchers.py",
4647
"chip/testing/matter_asserts.py",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
#
2+
# Copyright (c) 2025 Project CHIP Authors
3+
# All rights reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
#
17+
18+
"""Decorator utilities for Matter testing infrastructure.
19+
20+
This module provides decorator functions for Matter testing to handle async operations
21+
and endpoint matching.
22+
"""
23+
24+
import asyncio
25+
import logging
26+
from enum import IntFlag
27+
from functools import partial
28+
from typing import TYPE_CHECKING, Callable
29+
30+
import chip.clusters as Clusters
31+
from chip.clusters import Attribute
32+
from chip.clusters import ClusterObjects as ClusterObjects
33+
from chip.testing.global_attribute_ids import GlobalAttributeIds
34+
from mobly import asserts
35+
36+
# conditional import to avoid circular dependency but still allow type checking
37+
if TYPE_CHECKING:
38+
from chip.testing.matter_testing import MatterBaseTest
39+
40+
EndpointCheckFunction = Callable[[
41+
Clusters.Attribute.AsyncReadTransaction.ReadResponse, int], bool]
42+
43+
44+
def _has_cluster(wildcard: Clusters.Attribute.AsyncReadTransaction.ReadResponse, endpoint: int, cluster: ClusterObjects.Cluster) -> bool:
45+
"""Check if a cluster exists on a specific endpoint.
46+
47+
Args:
48+
wildcard: A wildcard read result containing endpoint attributes mapping
49+
endpoint: The endpoint ID to check
50+
cluster: The Cluster object to look for
51+
52+
Returns:
53+
bool: True if the cluster exists on the endpoint, False otherwise
54+
Returns False if endpoint is not found in wildcard attributes
55+
"""
56+
return endpoint in wildcard.attributes and cluster in wildcard.attributes[endpoint]
57+
58+
59+
def has_cluster(cluster: ClusterObjects.ClusterObjectDescriptor) -> EndpointCheckFunction:
60+
"""" EndpointCheckFunction that can be passed as a parameter to the run_if_endpoint_matches decorator.
61+
62+
Use this function with the run_if_endpoint_matches decorator to run this test on all endpoints with
63+
the specified cluster. For example, given a device with the following conformance
64+
65+
EP0: cluster A, B, C
66+
EP1: cluster D, E
67+
EP2, cluster D
68+
EP3, cluster E
69+
70+
And the following test specification:
71+
@run_if_endpoint_matches(has_cluster(Clusters.D))
72+
test_mytest(self):
73+
...
74+
75+
If you run this test with --endpoint 1 or --endpoint 2, the test will be run. If you run this test
76+
with any other --endpoint the run_if_endpoint_matches decorator will call the on_skip function to
77+
notify the test harness that the test is not applicable to this node and the test will not be run.
78+
"""
79+
return partial(_has_cluster, cluster=cluster)
80+
81+
82+
def _has_attribute(wildcard: Clusters.Attribute.AsyncReadTransaction.ReadResponse, endpoint: int, attribute: ClusterObjects.ClusterAttributeDescriptor) -> bool:
83+
"""Check if an attribute exists in a cluster's AttributeList on a specific endpoint.
84+
85+
Args:
86+
wildcard: A wildcard read result containing endpoint attributes mapping
87+
endpoint: The endpoint ID to check
88+
attribute: The ClusterAttributeDescriptor to look for
89+
90+
Returns:
91+
bool: True if the attribute ID exists in the cluster's AttributeList, False otherwise
92+
Returns False if endpoint, cluster, or AttributeList is not found
93+
94+
Raises:
95+
ValueError: If AttributeList value is not a list type
96+
KeyError: If attribute's cluster_id is not found in ALL_CLUSTERS
97+
"""
98+
cluster = ClusterObjects.ALL_CLUSTERS[attribute.cluster_id]
99+
100+
if endpoint not in wildcard.attributes:
101+
return False
102+
103+
if cluster not in wildcard.attributes[endpoint]:
104+
return False
105+
106+
if cluster.Attributes.AttributeList not in wildcard.attributes[endpoint][cluster]:
107+
return False
108+
109+
attr_list = wildcard.attributes[endpoint][cluster][cluster.Attributes.AttributeList]
110+
if not isinstance(attr_list, list):
111+
raise ValueError(
112+
f"Failed to read mandatory AttributeList attribute value for cluster {cluster} on endpoint {endpoint}: {attr_list}.")
113+
114+
return attribute.attribute_id in attr_list
115+
116+
117+
def has_attribute(attribute: ClusterObjects.ClusterAttributeDescriptor) -> EndpointCheckFunction:
118+
""" EndpointCheckFunction that can be passed as a parameter to the run_if_endpoint_matches decorator.
119+
120+
Use this function with the run_if_endpoint_matches decorator to run this test on all endpoints with
121+
the specified attribute. For example, given a device with the following conformance
122+
123+
EP0: cluster A, B, C
124+
EP1: cluster D with attribute d, E
125+
EP2, cluster D with attribute d
126+
EP3, cluster D without attribute d
127+
128+
And the following test specification:
129+
@run_if_endpoint_matches(has_attribute(Clusters.D.Attributes.d))
130+
test_mytest(self):
131+
...
132+
133+
If you run this test with --endpoint 1 or --endpoint 2, the test will be run. If you run this test
134+
with any other --endpoint the run_if_endpoint_matches decorator will call the on_skip function to
135+
notify the test harness that the test is not applicable to this node and the test will not be run.
136+
"""
137+
return partial(_has_attribute, attribute=attribute)
138+
139+
140+
def _has_command(wildcard: Clusters.Attribute.AsyncReadTransaction.ReadResponse, endpoint: int, command: ClusterObjects.ClusterCommand) -> bool:
141+
"""Check if a command exists in a cluster's AcceptedCommandList on a specific endpoint.
142+
143+
Args:
144+
wildcard: A wildcard read result containing endpoint attributes mapping
145+
endpoint: The endpoint ID to check
146+
command: The ClusterCommand to look for
147+
148+
Returns:
149+
bool: True if the command ID exists in the cluster's AcceptedCommandList, False otherwise
150+
Returns False if endpoint, cluster, or AcceptedCommandList is not found
151+
152+
Raises:
153+
ValueError: If AcceptedCommandList value is not a list type
154+
KeyError: If command's cluster_id is not found in ALL_CLUSTERS
155+
"""
156+
cluster = ClusterObjects.ALL_CLUSTERS[command.cluster_id]
157+
158+
if endpoint not in wildcard.attributes:
159+
return False
160+
161+
if cluster not in wildcard.attributes[endpoint]:
162+
return False
163+
164+
if cluster.Attributes.AcceptedCommandList not in wildcard.attributes[endpoint][cluster]:
165+
return False
166+
167+
cmd_list = wildcard.attributes[endpoint][cluster][cluster.Attributes.AcceptedCommandList]
168+
if not isinstance(cmd_list, list):
169+
raise ValueError(
170+
f"Failed to read mandatory AcceptedCommandList command value for cluster {cluster} on endpoint {endpoint}: {cmd_list}.")
171+
172+
return command.command_id in cmd_list
173+
174+
175+
def has_command(command: ClusterObjects.ClusterCommand) -> EndpointCheckFunction:
176+
""" EndpointCheckFunction that can be passed as a parameter to the run_if_endpoint_matches decorator.
177+
178+
Use this function with the run_if_endpoint_matches decorator to run this test on all endpoints with
179+
the specified attribute. For example, given a device with the following conformance
180+
181+
EP0: cluster A, B, C
182+
EP1: cluster D with command d, E
183+
EP2, cluster D with command d
184+
EP3, cluster D without command d
185+
186+
And the following test specification:
187+
@run_if_endpoint_matches(has_command(Clusters.D.Commands.d))
188+
test_mytest(self):
189+
...
190+
191+
If you run this test with --endpoint 1 or --endpoint 2, the test will be run. If you run this test
192+
with any other --endpoint the run_if_endpoint_matches decorator will call the on_skip function to
193+
notify the test harness that the test is not applicable to this node and the test will not be run.
194+
"""
195+
return partial(_has_command, command=command)
196+
197+
198+
def _has_feature(wildcard: Clusters.Attribute.AsyncReadTransaction.ReadResponse, endpoint: int, cluster: ClusterObjects.ClusterObjectDescriptor, feature: IntFlag) -> bool:
199+
if endpoint not in wildcard.attributes:
200+
return False
201+
202+
if cluster not in wildcard.attributes[endpoint]:
203+
return False
204+
205+
if cluster.Attributes.FeatureMap not in wildcard.attributes[endpoint][cluster]:
206+
return False
207+
208+
feature_map = wildcard.attributes[endpoint][cluster][cluster.Attributes.FeatureMap]
209+
if not isinstance(feature_map, int):
210+
raise ValueError(
211+
f"Failed to read mandatory FeatureMap attribute value for cluster {cluster} on endpoint {endpoint}: {feature_map}.")
212+
213+
return (feature & feature_map) != 0
214+
215+
216+
def has_feature(cluster: ClusterObjects.ClusterObjectDescriptor, feature: IntFlag) -> EndpointCheckFunction:
217+
""" EndpointCheckFunction that can be passed as a parameter to the run_if_endpoint_matches decorator.
218+
219+
Use this function with the run_if_endpoint_matches decorator to run this test on all endpoints with
220+
the specified feature. For example, given a device with the following conformance
221+
222+
EP0: cluster A, B, C
223+
EP1: cluster D with feature F0
224+
EP2: cluster D with feature F0
225+
EP3: cluster D without feature F0
226+
227+
And the following test specification:
228+
@run_if_endpoint_matches(has_feature(Clusters.D.Bitmaps.Feature.F0))
229+
test_mytest(self):
230+
...
231+
232+
If you run this test with --endpoint 1 or --endpoint 2, the test will be run. If you run this test
233+
with any other --endpoint the run_if_endpoint_matches decorator will call the on_skip function to
234+
notify the test harness that the test is not applicable to this node and the test will not be run.
235+
"""
236+
return partial(_has_feature, cluster=cluster, feature=feature)
237+
238+
239+
def _async_runner(body, test_instance, *args, **kwargs):
240+
timeout = getattr(test_instance.matter_test_config,
241+
'timeout', None) or test_instance.default_timeout
242+
return test_instance.event_loop.run_until_complete(asyncio.wait_for(body(test_instance, *args, **kwargs), timeout=timeout))
243+
244+
245+
def async_test_body(body):
246+
"""Decorator required to be applied whenever a `test_*` method is `async def`.
247+
248+
Since Mobly doesn't support asyncio directly, and the test methods are called
249+
synchronously, we need a mechanism to allow an `async def` to be converted to
250+
a asyncio-run synchronous method. This decorator does the wrapping.
251+
"""
252+
253+
def async_runner(self: "MatterBaseTest", *args, **kwargs):
254+
return _async_runner(body, self, *args, **kwargs)
255+
return async_runner
256+
257+
258+
async def _get_all_matching_endpoints(test_instance, accept_function: EndpointCheckFunction) -> list[int]:
259+
""" Returns a list of endpoints matching the accept condition. """
260+
wildcard = await test_instance.default_controller.Read(test_instance.dut_node_id, [(Clusters.Descriptor), Attribute.AttributePath(None, None, GlobalAttributeIds.ATTRIBUTE_LIST_ID), Attribute.AttributePath(None, None, GlobalAttributeIds.FEATURE_MAP_ID), Attribute.AttributePath(None, None, GlobalAttributeIds.ACCEPTED_COMMAND_LIST_ID)])
261+
matching = [e for e in wildcard.attributes.keys()
262+
if accept_function(wildcard, e)]
263+
return matching
264+
265+
266+
async def should_run_test_on_endpoint(test_instance, accept_function: EndpointCheckFunction) -> bool:
267+
""" Helper function for the run_if_endpoint_matches decorator.
268+
269+
Returns True if test_instance.matter_test_config.endpoint matches the accept function.
270+
"""
271+
if test_instance.matter_test_config.endpoint is None:
272+
msg = """
273+
The --endpoint flag is required for this test.
274+
"""
275+
asserts.fail(msg)
276+
matching = await (_get_all_matching_endpoints(test_instance, accept_function))
277+
return test_instance.matter_test_config.endpoint in matching
278+
279+
280+
def run_on_singleton_matching_endpoint(accept_function: EndpointCheckFunction):
281+
""" Test decorator for a test that needs to be run on the endpoint that matches the given accept function.
282+
283+
This decorator should be used for tests where the endpoint is not known a-priori (dynamic endpoints).
284+
Note that currently this test is limited to devices with a SINGLE matching endpoint.
285+
"""
286+
def run_on_singleton_matching_endpoint_internal(body):
287+
def matching_runner(self: "MatterBaseTest", *args, **kwargs):
288+
# Import locally to avoid circular dependency
289+
from chip.testing.matter_testing import MatterBaseTest
290+
assert isinstance(self, MatterBaseTest)
291+
292+
runner_with_timeout = asyncio.wait_for(
293+
_get_all_matching_endpoints(self, accept_function), timeout=30)
294+
matching = self.event_loop.run_until_complete(runner_with_timeout)
295+
asserts.assert_less_equal(
296+
len(matching), 1, "More than one matching endpoint found for singleton test.")
297+
if not matching:
298+
logging.info(
299+
"Test is not applicable to any endpoint - skipping test")
300+
asserts.skip('No endpoint matches test requirements')
301+
return
302+
try:
303+
old_endpoint = self.matter_test_config.endpoint
304+
self.matter_test_config.endpoint = matching[0]
305+
logging.info(
306+
f'Running test on endpoint {self.matter_test_config.endpoint}')
307+
timeout = getattr(self.matter_test_config,
308+
'timeout', None) or self.default_timeout
309+
self.event_loop.run_until_complete(asyncio.wait_for(
310+
body(self, *args, **kwargs), timeout=timeout))
311+
finally:
312+
self.matter_test_config.endpoint = old_endpoint
313+
return matching_runner
314+
return run_on_singleton_matching_endpoint_internal
315+
316+
317+
def run_if_endpoint_matches(accept_function: EndpointCheckFunction):
318+
""" Test decorator for a test that needs to be run only if the endpoint meets the accept_function criteria.
319+
320+
Place this decorator above the test_ method to have the test framework run this test only if the endpoint matches.
321+
This decorator takes an EndpointCheckFunction to assess whether a test needs to be run on a particular
322+
endpoint.
323+
324+
For example, given the following device conformance:
325+
326+
EP0: cluster A, B, C
327+
EP1: cluster D, E
328+
EP2, cluster D
329+
EP3, cluster E
330+
331+
And the following test specification:
332+
@run_if_endpoint_matches(has_cluster(Clusters.D))
333+
test_mytest(self):
334+
...
335+
336+
If you run this test with --endpoint 1 or --endpoint 2, the test will be run. If you run this test
337+
with any other --endpoint the decorator will call the on_skip function to
338+
notify the test harness that the test is not applicable to this node and the test will not be run.
339+
340+
Tests that use this decorator cannot use a pics_ method for test selection and should not reference any
341+
PICS values internally.
342+
"""
343+
def run_if_endpoint_matches_internal(body):
344+
def per_endpoint_runner(test_instance, *args, **kwargs):
345+
runner_with_timeout = asyncio.wait_for(
346+
should_run_test_on_endpoint(test_instance, accept_function), timeout=60)
347+
should_run_test = test_instance.event_loop.run_until_complete(
348+
runner_with_timeout)
349+
if not should_run_test:
350+
logging.info(
351+
"Test is not applicable to this endpoint - skipping test")
352+
asserts.skip('Endpoint does not match test requirements')
353+
return
354+
logging.info(
355+
f'Running test on endpoint {test_instance.matter_test_config.endpoint}')
356+
timeout = getattr(test_instance.matter_test_config,
357+
'timeout', None) or test_instance.default_timeout
358+
test_instance.event_loop.run_until_complete(asyncio.wait_for(
359+
body(test_instance, *args, **kwargs), timeout=timeout))
360+
return per_endpoint_runner
361+
return run_if_endpoint_matches_internal

0 commit comments

Comments
 (0)