Skip to content

Commit bfaa466

Browse files
vivien-applebzbarsky-apple
authored andcommitted
[MatterYamlTests] Allow enum names in YAML instead of raw values (project-chip#32107)
* [YAML] Allow the YAML tests to use the enum names instead of the raw value * Update the YAML tests * [MatterYamlTests] Get test_yaml_parser.py to be runned in CI * [MatterYamlTests] Add tests to test_yaml_parser.py * Update errors.py Co-authored-by: Boris Zbarsky <bzbarsky@apple.com> --------- Co-authored-by: Boris Zbarsky <bzbarsky@apple.com>
1 parent 9237cd7 commit bfaa466

16 files changed

+394
-56
lines changed

scripts/py_matter_yamltests/BUILD.gn

+1
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pw_python_package("matter_yamltests") {
5555
"test_pics_checker.py",
5656
"test_parser_builder.py",
5757
"test_pseudo_clusters.py",
58+
"test_yaml_parser.py",
5859
"test_yaml_loader.py",
5960
]
6061

scripts/py_matter_yamltests/matter_yamltests/errors.py

+42
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,45 @@ def __init__(self, content):
222222
self.tag_key_with_error(content, 'attribute')
223223
response = content.get('response')
224224
self.tag_key_with_error(response, 'saveAs')
225+
226+
227+
class TestStepEnumError(TestStepError):
228+
"""
229+
Raise when an enum value or an enum name is not found in the definitions.
230+
231+
Parameters:
232+
- enum_name_or_value (str|int): The name (str) or value (int) of the enumeration in the step.
233+
If a string is provided, it is considered the name of the enumeration; if an integer is provided, it is considered the value of the enumeration.
234+
- enum_candidates (dict): A dictionary mapping enumeration names (as strings) to their corresponding values
235+
(as integers). This dictionary represents all known values of the enumeration.
236+
"""
237+
238+
def __init__(self, enum_name_or_value, enum_candidates: dict):
239+
if type(enum_name_or_value) is str:
240+
message = f'Unknown enum name: "{enum_name_or_value}". The possible values are: "{enum_candidates}"'
241+
242+
for enum_name in enum_candidates:
243+
if enum_name.lower() == enum_name_or_value.lower():
244+
message = f'Unknown enum name: "{enum_name_or_value}". Did you mean "{enum_name}" ?'
245+
break
246+
247+
else:
248+
message = f'Unknown enum value: "{enum_name_or_value}". The possible values are: "{enum_candidates}"'
249+
250+
super().__init__(message)
251+
252+
253+
class TestStepEnumSpecifierNotUnknownError(TestStepError):
254+
"""Raise when an enum value declared as unknown is in fact a known enum value from the definitions."""
255+
256+
def __init__(self, specified_value, enum_name):
257+
message = f'The value "{specified_value}" is not unknown. It is the value of "{enum_name}"'
258+
super().__init__(message)
259+
260+
261+
class TestStepEnumSpecifierWrongError(TestStepError):
262+
"""Raise when an enum value is specified for a given enum name but it does not match the enum value from the definitions."""
263+
264+
def __init__(self, specified_value, enum_name, enum_value):
265+
message = f'The value "{specified_value}" is not the value of "{enum_name}({enum_value})"'
266+
super().__init__(message)

scripts/py_matter_yamltests/matter_yamltests/parser.py

+164-15
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,16 @@
1515

1616
import copy
1717
import logging
18+
import re
1819
from dataclasses import dataclass, field
1920
from enum import Enum, auto
2021
from typing import Optional
2122

2223
from . import fixes
2324
from .constraints import get_constraints, is_typed_constraint
2425
from .definitions import SpecDefinitions
25-
from .errors import TestStepError, TestStepKeyError, TestStepValueNameError
26+
from .errors import (TestStepEnumError, TestStepEnumSpecifierNotUnknownError, TestStepEnumSpecifierWrongError, TestStepError,
27+
TestStepKeyError, TestStepValueNameError)
2628
from .pics_checker import PICSChecker
2729
from .yaml_loader import YamlLoader
2830

@@ -38,6 +40,9 @@
3840
'SubscribeAll',
3941
]
4042

43+
# If True, enum values should use a valid name instead of a raw value
44+
STRICT_ENUM_VALUE_CHECK = False
45+
4146

4247
class UnknownPathQualifierError(TestStepError):
4348
"""Raise when an attribute/command/event name is not found in the definitions."""
@@ -169,6 +174,99 @@ def _value_or_config(data, key, config):
169174
return data[key] if key in data else config.get(key)
170175

171176

177+
class EnumType:
178+
def __init__(self, enum: Enum):
179+
self.type = enum.name
180+
self.base_type = enum.base_type
181+
182+
self._codes = {}
183+
self.entries_by_name = {}
184+
self.entries_by_code = {}
185+
self._compute_entries(enum)
186+
187+
def translate(self, key: str, value) -> int:
188+
if self._codes.get(key) is not None and self._codes.get(key) == value:
189+
return self._codes.get(key)
190+
191+
if type(value) is str:
192+
code = self._get_code_by_name(value)
193+
else:
194+
code = self._get_code_by_value(value)
195+
196+
if code is None:
197+
raise TestStepEnumError(value, self.entries_by_name)
198+
199+
self._codes[key] = code
200+
return code
201+
202+
def _get_code_by_name(self, value):
203+
# For readability the name could sometimes be written as "enum_name(enum_code)" instead of "enum_name"
204+
# In this case the enum_code should be checked to ensure that it is correct, unless enum_name is UnknownEnumValue
205+
# in which case only invalid enum_code are allowed.
206+
specified_name, specified_code = self._extract_name_and_code(value)
207+
if specified_name not in self.entries_by_name:
208+
return None
209+
210+
enum_code = self.entries_by_name.get(specified_name)
211+
if specified_code is None or specified_code == enum_code:
212+
return enum_code
213+
214+
if specified_name != f'{self.type}.UnknownEnumValue':
215+
raise TestStepEnumSpecifierWrongError(
216+
specified_code, specified_name, enum_code)
217+
218+
enum_name = self.entries_by_code.get(specified_code)
219+
if enum_name:
220+
raise TestStepEnumSpecifierNotUnknownError(value, enum_name)
221+
222+
return specified_code
223+
224+
def _get_code_by_value(self, value):
225+
enum_name = self.entries_by_code.get(value)
226+
if not enum_name:
227+
return None
228+
229+
if STRICT_ENUM_VALUE_CHECK:
230+
raise TestStepEnumError(value, self.entries_by_name)
231+
232+
return value
233+
234+
def _compute_entries(self, enum: Enum):
235+
enum_codes = []
236+
for enum_entry in enum.entries:
237+
name = f'{self.type}.{enum_entry.name}'
238+
code = enum_entry.code
239+
240+
self.entries_by_name[name] = code
241+
self.entries_by_code[code] = name
242+
enum_codes.append(code)
243+
244+
# search for the first invalid entry if any
245+
max_code = 0xFF + 1
246+
if self.base_type == 'enum16':
247+
max_code = 0xFFFF + 1
248+
249+
for code in range(0, max_code):
250+
if code not in enum_codes:
251+
name = f'{self.type}.UnknownEnumValue'
252+
self.entries_by_name[name] = code
253+
self.entries_by_code[code] = name
254+
break
255+
256+
def _extract_name_and_code(self, enum_name: str):
257+
match = re.match(r"([\w.]+)(?:\((\w+)\))?", enum_name)
258+
if match:
259+
name = match.group(1)
260+
code = int(match.group(2)) if match.group(2) else None
261+
return name, code
262+
263+
return None, None
264+
265+
@staticmethod
266+
def is_valid_type(target_type: str):
267+
return target_type == 'enum8' or target_type == 'enum16'
268+
269+
172270
class _TestStepWithPlaceholders:
173271
'''A single YAML test parsed, as is, from YAML.
174272
@@ -441,7 +539,11 @@ def _as_mapping(self, definitions, cluster_name, target_name):
441539
element = definitions.get_type_by_name(cluster_name, target_name)
442540

443541
if hasattr(element, 'base_type'):
444-
target_name = element.base_type.lower()
542+
if EnumType.is_valid_type(element.base_type):
543+
target_name = EnumType(element)
544+
else:
545+
target_name = element.base_type
546+
445547
elif hasattr(element, 'fields'):
446548
target_name = {f.name: self._as_mapping(
447549
definitions, cluster_name, f.data_type.name) for f in element.fields}
@@ -480,7 +582,11 @@ def _update_with_definition(self, container: dict, mapping_type):
480582

481583
if key == 'value':
482584
value[key] = self._update_value_with_definition(
483-
item_value, mapping)
585+
value,
586+
key,
587+
item_value,
588+
mapping
589+
)
484590
elif key == 'saveAs' and type(item_value) is str and item_value not in self._parsing_config_variable_storage:
485591
self._parsing_config_variable_storage[item_value] = None
486592
elif key == 'saveDataVersionAs' and type(item_value) is str and item_value not in self._parsing_config_variable_storage:
@@ -491,37 +597,80 @@ def _update_with_definition(self, container: dict, mapping_type):
491597
# the the value type for the target field.
492598
if is_typed_constraint(constraint):
493599
value[key][constraint] = self._update_value_with_definition(
494-
constraint_value, mapping)
600+
item_value,
601+
constraint,
602+
constraint_value,
603+
mapping
604+
)
495605
else:
496606
# This key, value pair does not rely on cluster specifications.
497607
pass
498608

499-
def _update_value_with_definition(self, value, mapping_type):
609+
def _update_value_with_definition(self, container: dict, key: str, value, mapping_type):
610+
"""
611+
Processes a given value based on a specified mapping type and returns the updated value.
612+
This method does not modify the container in place; rather, it returns a new value that should be
613+
used to update or process further as necessary.
614+
615+
The 'container' and 'key' parameters are primarily used for error tagging. If an error occurs
616+
during the value processing, these parameters allow for the error to be precisely located and
617+
reported, facilitating easier debugging and error tracking.
618+
619+
Parameters:
620+
- container (dict): A dictionary that serves as a context for the operation. It is used for error
621+
tagging if processing fails, by associating errors with specific locations within the data structure.
622+
- key (str): The key related to the value being processed. It is used alongside 'container' to tag
623+
errors, enabling precise identification of the error source.
624+
- value: The value to be processed according to the mapping type.
625+
- mapping_type: Dictates the processing or mapping logic to be applied to 'value'.
626+
627+
Returns:
628+
The processed value, which is the result of applying the specified mapping type to the original 'value'.
629+
This method does not update the 'container'; any necessary updates based on the processed value must
630+
be handled outside this method.
631+
632+
Raises:
633+
- TestStepError: If an error occurs during the processing of the value. The error includes details
634+
from the 'container' and 'key' to facilitate error tracing and debugging.
635+
"""
636+
500637
if not mapping_type:
501638
return value
502639

503640
if type(value) is dict:
504641
rv = {}
505-
for key in value:
642+
for item_key in value:
506643
# FabricIndex is a special case where the framework requires it to be passed even
507644
# if it is not part of the requested arguments per spec and not part of the XML
508645
# definition.
509-
if key == 'FabricIndex' or key == 'fabricIndex':
510-
rv[key] = value[key] # int64u
646+
if item_key == 'FabricIndex' or item_key == 'fabricIndex':
647+
rv[item_key] = value[item_key] # int64u
511648
else:
512-
if not mapping_type.get(key):
513-
raise TestStepKeyError(value, key)
514-
mapping = mapping_type[key]
515-
rv[key] = self._update_value_with_definition(
516-
value[key], mapping)
649+
if not mapping_type.get(item_key):
650+
raise TestStepKeyError(value, item_key)
651+
mapping = mapping_type[item_key]
652+
rv[item_key] = self._update_value_with_definition(
653+
value,
654+
item_key,
655+
value[item_key],
656+
mapping
657+
)
517658
return rv
659+
518660
if type(value) is list:
519-
return [self._update_value_with_definition(entry, mapping_type) for entry in value]
661+
return [self._update_value_with_definition(container, key, entry, mapping_type) for entry in value]
662+
520663
# TODO currently unsure if the check of `value not in config` is sufficant. For
521664
# example let's say value = 'foo + 1' and map type is 'int64u', we would arguably do
522665
# the wrong thing below.
523666
if value is not None and value not in self._parsing_config_variable_storage:
524-
if mapping_type == 'int64u' or mapping_type == 'int64s' or mapping_type == 'bitmap64' or mapping_type == 'epoch_us':
667+
if type(mapping_type) is EnumType:
668+
try:
669+
value = mapping_type.translate(key, value)
670+
except (TestStepEnumError, TestStepEnumSpecifierNotUnknownError, TestStepEnumSpecifierWrongError) as e:
671+
e.tag_key_with_error(container, key)
672+
raise e
673+
elif mapping_type == 'int64u' or mapping_type == 'int64s' or mapping_type == 'bitmap64' or mapping_type == 'epoch_us':
525674
value = fixes.try_apply_float_to_integer_fix(value)
526675
value = fixes.try_apply_yaml_cpp_longlong_limitation_fix(value)
527676
value = fixes.try_apply_yaml_unrepresentable_integer_for_javascript_fixes(

0 commit comments

Comments
 (0)