From 62cd0b74c026e0ace6adcfd32d04e0c9a987cac8 Mon Sep 17 00:00:00 2001 From: Vivien Nicolas Date: Sat, 21 Jan 2023 14:20:57 +0100 Subject: [PATCH] [matter_yamltests] Add PICS checker (#24525) --- scripts/py_matter_yamltests/BUILD.gn | 6 +- .../matter_yamltests/parser.py | 50 +++-- .../matter_yamltests/pics_checker.py | 185 ++++++++++++++++++ .../py_matter_yamltests/test_pics_checker.py | 168 ++++++++++++++++ 4 files changed, 389 insertions(+), 20 deletions(-) create mode 100644 scripts/py_matter_yamltests/matter_yamltests/pics_checker.py create mode 100644 scripts/py_matter_yamltests/test_pics_checker.py diff --git a/scripts/py_matter_yamltests/BUILD.gn b/scripts/py_matter_yamltests/BUILD.gn index 88ba5a41b1cc19..8aa7ca8746e440 100644 --- a/scripts/py_matter_yamltests/BUILD.gn +++ b/scripts/py_matter_yamltests/BUILD.gn @@ -31,11 +31,15 @@ pw_python_package("matter_yamltests") { "matter_yamltests/definitions.py", "matter_yamltests/fixes.py", "matter_yamltests/parser.py", + "matter_yamltests/pics_checker.py", ] python_deps = [ "${chip_root}/scripts/py_matter_idl:matter_idl" ] - tests = [ "test_spec_definitions.py" ] + tests = [ + "test_spec_definitions.py", + "test_pics_checker.py", + ] # TODO: at a future time consider enabling all (* or missing) here to get # pylint checking these files diff --git a/scripts/py_matter_yamltests/matter_yamltests/parser.py b/scripts/py_matter_yamltests/matter_yamltests/parser.py index 1f262f01100b63..13dc7156dd1f78 100644 --- a/scripts/py_matter_yamltests/matter_yamltests/parser.py +++ b/scripts/py_matter_yamltests/matter_yamltests/parser.py @@ -20,6 +20,8 @@ from . import fixes from .constraints import get_constraints, is_typed_constraint +from .definitions import SpecDefinitions +from .pics_checker import PICSChecker _TESTS_SECTION = [ 'name', @@ -182,7 +184,7 @@ class _TestStepWithPlaceholders: processed. ''' - def __init__(self, test: dict, config: dict, definitions): + def __init__(self, test: dict, config: dict, definitions: SpecDefinitions, pics_checker: PICSChecker): # Disabled tests are not parsed in order to allow the test to be added to the test # suite even if the feature is not implemented yet. self.is_enabled = not ('disabled' in test and test['disabled']) @@ -200,6 +202,7 @@ def __init__(self, test: dict, config: dict, definitions): self.command = _value_or_config(test, 'command', config) self.attribute = _value_or_none(test, 'attribute') self.endpoint = _value_or_config(test, 'endpoint', config) + self.is_pics_enabled = pics_checker.check(_value_or_none(test, 'PICS')) self.identity = _value_or_none(test, 'identity') self.fabric_filtered = _value_or_none(test, 'fabricFiltered') @@ -389,6 +392,10 @@ def __init__(self, test: _TestStepWithPlaceholders, runtime_config_variable_stor def is_enabled(self): return self._test.is_enabled + @property + def is_pics_enabled(self): + return self._test.is_pics_enabled + @property def is_attribute(self): return self._test.is_attribute @@ -690,8 +697,9 @@ def _config_variable_substitution(self, value): variable_info = self._runtime_config_variable_storage[token] if type(variable_info) is dict and 'defaultValue' in variable_info: variable_info = variable_info['defaultValue'] - tokens[idx] = variable_info - substitution_occured = True + if variable_info is not None: + tokens[idx] = variable_info + substitution_occured = True if len(tokens) == 1: return tokens[0] @@ -716,12 +724,12 @@ class YamlTests: multiple runs. ''' - def __init__(self, parsing_config_variable_storage: dict, definitions, tests: dict): + def __init__(self, parsing_config_variable_storage: dict, definitions: SpecDefinitions, pics_checker: PICSChecker, tests: dict): self._parsing_config_variable_storage = parsing_config_variable_storage enabled_tests = [] for test in tests: test_with_placeholders = _TestStepWithPlaceholders( - test, self._parsing_config_variable_storage, definitions) + test, self._parsing_config_variable_storage, definitions, pics_checker) if test_with_placeholders.is_enabled: enabled_tests.append(test_with_placeholders) fixes.try_update_yaml_node_id_test_runner_state( @@ -748,24 +756,28 @@ def __next__(self) -> TestStep: class TestParser: def __init__(self, test_file, pics_file, definitions): - # TODO Needs supports for PICS file - with open(test_file) as f: - loader = yaml.FullLoader - loader = fixes.try_add_yaml_support_for_scientific_notation_without_dot( - loader) + data = self.__load_yaml(test_file) - data = yaml.load(f, Loader=loader) - _check_valid_keys(data, _TESTS_SECTION) + _check_valid_keys(data, _TESTS_SECTION) - self.name = _value_or_none(data, 'name') - self.PICS = _value_or_none(data, 'PICS') + self.name = _value_or_none(data, 'name') + self.PICS = _value_or_none(data, 'PICS') - self._parsing_config_variable_storage = _value_or_none( - data, 'config') + self._parsing_config_variable_storage = _value_or_none(data, 'config') - tests = _value_or_none(data, 'tests') - self.tests = YamlTests( - self._parsing_config_variable_storage, definitions, tests) + pics_checker = PICSChecker(pics_file) + tests = _value_or_none(data, 'tests') + self.tests = YamlTests( + self._parsing_config_variable_storage, definitions, pics_checker, tests) def update_config(self, key, value): self._parsing_config_variable_storage[key] = value + + def __load_yaml(self, test_file): + with open(test_file) as f: + loader = yaml.FullLoader + loader = fixes.try_add_yaml_support_for_scientific_notation_without_dot( + loader) + + return yaml.load(f, Loader=loader) + return None diff --git a/scripts/py_matter_yamltests/matter_yamltests/pics_checker.py b/scripts/py_matter_yamltests/matter_yamltests/pics_checker.py new file mode 100644 index 00000000000000..580c83dfd696c6 --- /dev/null +++ b/scripts/py_matter_yamltests/matter_yamltests/pics_checker.py @@ -0,0 +1,185 @@ +# +# Copyright (c) 2023 Project CHIP Authors +# +# 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. + +import unicodedata + +_COMMENT_CHARACTER = '#' +_VALUE_SEPARATOR = '=' +_VALUE_DISABLED = '0' +_VALUE_ENABLED = '1' +_CONTROL_CHARACTER_IDENTIFIER = 'C' + + +class InvalidPICSConfigurationError(Exception): + "Raised when the configured pics entry can not be parsed." + pass + + +class InvalidPICSConfigurationValueError(Exception): + "Raised when the configured pics value is not an authorized value." + pass + + +class InvalidPICSParsingError(Exception): + "Raised when a parsing error occured." + pass + + +class PICSChecker(): + """Class to compute a PICS expression""" + __pics: None + __expression_index: 0 + + def __init__(self, pics_file: str): + if pics_file is not None: + self.__pics = self.__parse(pics_file) + + def check(self, pics) -> bool: + if pics is None: + return True + + self.__expression_index = 0 + tokens = self.__tokenize(pics) + return self.__evaluate_expression(tokens, self.__pics) + + def __parse(self, pics_file: str): + pics = {} + with open(pics_file) as f: + line = f.readline() + while line: + preprocessed_line = self.__preprocess_input(line) + if preprocessed_line: + items = preprocessed_line.split(_VALUE_SEPARATOR) + # There should always be one key and one value, nothing else. + if len(items) != 2: + raise InvalidPICSConfigurationError( + f'Invalid expression: {line}') + + key, value = items + if value != _VALUE_DISABLED and value != _VALUE_ENABLED: + raise InvalidPICSConfigurationValueError( + f'Invalid expression: {line}') + + pics[key] = value == _VALUE_ENABLED + + line = f.readline() + return pics + + def __evaluate_expression(self, tokens: list[str], pics: dict): + leftExpr = self.__evaluate_sub_expression(tokens, pics) + if self.__expression_index >= len(tokens): + return leftExpr + + token = tokens[self.__expression_index] + + if token == ')': + return leftExpr + + token = tokens[self.__expression_index] + + if token == '&&': + self.__expression_index += 1 + rightExpr = self.__evaluate_sub_expression(tokens, pics) + return leftExpr and rightExpr + + if token == '||': + self.__expression_index += 1 + rightExpr = self.__evaluate_sub_expression(tokens, pics) + return leftExpr or rightExpr + + raise InvalidPICSParsingError(f'Unknown token: {token}') + + def __evaluate_sub_expression(self, tokens: list[str], pics: dict): + token = tokens[self.__expression_index] + if token == '(': + self.__expression_index += 1 + expr = self.__evaluate_expression(tokens, pics) + if tokens[self.__expression_index] != ')': + raise KeyError('Missing ")"') + + self.__expression_index += 1 + return expr + + if token == '!': + self.__expression_index += 1 + expr = self.__evaluate_expression(tokens, pics) + return not expr + + token = self.__normalize(token) + self.__expression_index += 1 + + if pics.get(token) == None: + # By default, let's consider that if a PICS item is not defined, it is |false|. + # It allows to create a file that only contains enabled features. + return False + + return pics.get(token) + + def __tokenize(self, expression: str): + token = '' + tokens = [] + + for c in expression: + if c == ' ' or c == '\t' or c == '\n': + pass + elif c == '(' or c == ')' or c == '!': + if token: + tokens.append(token) + token = '' + tokens.append(c) + elif c == '&' or c == '|': + if token and token[-1] == c: + token = token[:-1] + if token: + tokens.append(token) + token = '' + tokens.append(c + c) + else: + token += c + else: + token += c + + if token: + tokens.append(token) + token = '' + + return tokens + + def __preprocess_input(self, value: str): + value = self.__remove_comments(value) + value = self.__remove_control_characters(value) + value = self.__remove_whitespaces(value) + value = self.__make_lowercase(value) + return value + + def __remove_comments(self, value: str) -> str: + return value if not value else value.split(_COMMENT_CHARACTER, 1)[0] + + def __remove_control_characters(self, value: str) -> str: + return ''.join(c for c in value if unicodedata.category(c)[0] != _CONTROL_CHARACTER_IDENTIFIER) + + def __remove_whitespaces(self, value: str) -> str: + return value.replace(' ', '') + + def __make_lowercase(self, value: str) -> str: + return value.lower() + + def __normalize(self, token: str): + # Convert to all-lowercase so people who mess up cases don't have things + # break on them in subtle ways. + token = self.__make_lowercase(token) + + # TODO strip off "(Additional Context)" bits from the end of the code. + return token diff --git a/scripts/py_matter_yamltests/test_pics_checker.py b/scripts/py_matter_yamltests/test_pics_checker.py new file mode 100644 index 00000000000000..df3364c0d21c4b --- /dev/null +++ b/scripts/py_matter_yamltests/test_pics_checker.py @@ -0,0 +1,168 @@ +#!/usr/bin/env -S python3 -B +# +# Copyright (c) 2022 Project CHIP Authors +# +# 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. + +import io +import unittest +from unittest.mock import mock_open, patch + +from matter_yamltests.pics_checker import InvalidPICSConfigurationError, InvalidPICSConfigurationValueError, PICSChecker + +empty_config = '' + +simple_config = ''' +A.A=0 +A.B=1 +A.C=0 +''' + +simple_config_with_whitespaces_and_control_characters = ''' + A.A=0 \n +\tA.B = 1 +''' + +simple_config_with_comments = ''' +# This is a comment +A.A=0 +# This is an other comment +A.B=1 +A.C=1 # This is a comment +''' + +simple_config_with_invalid_entry_1 = ''' +A.A=FOO +''' + +simple_config_with_invalid_entry_2 = ''' +A.B=2 +''' + +simple_config_with_invalid_entry_3 = ''' +A.C= +''' + +simple_config_with_invalid_entry_4 = ''' +A.E== +''' + +simple_config_with_invalid_entry_5 = ''' +A.D +''' + +simple_config_with_invalid_entry_6 = ''' +A.D=1= +''' + + +class TestPICSChecker(unittest.TestCase): + @patch('builtins.open', mock_open(read_data=empty_config)) + def test_empty_config(self): + pics_checker = PICSChecker('') + self.assertIsInstance(pics_checker, PICSChecker) + + @patch('builtins.open', mock_open(read_data=simple_config)) + def test_simple_config(self): + pics_checker = PICSChecker('') + + self.assertFalse(pics_checker.check('A.A')) + self.assertFalse(pics_checker.check('A.DoesNotExist')) + + self.assertTrue(pics_checker.check('A.B')) + self.assertTrue(pics_checker.check('A.b')) + self.assertTrue(pics_checker.check('a.b')) + self.assertTrue(pics_checker.check(' A.B')) + self.assertTrue(pics_checker.check('A.B ')) + + @patch('builtins.open', mock_open(read_data=simple_config)) + def test_logic_negation(self): + pics_checker = PICSChecker('') + self.assertFalse(pics_checker.check('A.C')) + self.assertTrue(pics_checker.check('!A.C')) + self.assertFalse(pics_checker.check('!!A.C')) + self.assertTrue(pics_checker.check('!!!A.C')) + + @patch('builtins.open', mock_open(read_data=simple_config)) + def test_logical_and(self): + pics_checker = PICSChecker('') + self.assertFalse(pics_checker.check('A.C && A.B')) + self.assertFalse(pics_checker.check('A.C && A.B')) + self.assertTrue(pics_checker.check('!A.A && A.B')) + + @patch('builtins.open', mock_open(read_data=simple_config)) + def test_logical_or(self): + pics_checker = PICSChecker('') + self.assertFalse(pics_checker.check('A.A || A.C')) + self.assertTrue(pics_checker.check('A.B || A.C')) + self.assertTrue(pics_checker.check('!A.A || A.C')) + self.assertTrue(pics_checker.check('A.A || A.B || A.C')) + + @patch('builtins.open', mock_open(read_data=simple_config)) + def test_logical_parenthesis(self): + pics_checker = PICSChecker('') + self.assertFalse(pics_checker.check('(A.A)')) + self.assertTrue(pics_checker.check('(A.B)')) + self.assertTrue(pics_checker.check('!(A.A)')) + self.assertTrue(pics_checker.check('(!(A.A))')) + self.assertFalse(pics_checker.check('(A.A && A.B)')) + self.assertFalse(pics_checker.check('((A.A) && (A.B))')) + self.assertTrue(pics_checker.check('(!A.A && A.B)')) + self.assertTrue(pics_checker.check('(!(A.A) && (A.B))')) + self.assertTrue(pics_checker.check('(A.A || A.B)')) + self.assertFalse(pics_checker.check('A.C && (A.A || A.B)')) + self.assertTrue(pics_checker.check('A.B || (A.A || A.C)')) + self.assertTrue(pics_checker.check('!A.C && (A.A || A.B)')) + self.assertTrue(pics_checker.check('!A.C || (A.A && A.B)')) + + @patch('builtins.open', mock_open(read_data=simple_config_with_whitespaces_and_control_characters)) + def test_simple_config_with_whitespaces_and_control_characters(self): + pics_checker = PICSChecker('') + self.assertFalse(pics_checker.check('A.A')) + self.assertTrue(pics_checker.check('A.B')) + + @patch('builtins.open', mock_open(read_data=simple_config_with_comments)) + def test_simple_config_with_comments(self): + pics_checker = PICSChecker('') + self.assertFalse(pics_checker.check('A.A')) + self.assertTrue(pics_checker.check('A.B')) + self.assertTrue(pics_checker.check('A.C')) + + def test_simple_config_with_invalid_entry(self): + with patch("builtins.open", mock_open(read_data=simple_config_with_invalid_entry_1)) as mock_file: + self.assertRaises( + InvalidPICSConfigurationValueError, PICSChecker, mock_file) + + with patch("builtins.open", mock_open(read_data=simple_config_with_invalid_entry_2)) as mock_file: + self.assertRaises( + InvalidPICSConfigurationValueError, PICSChecker, mock_file) + + with patch("builtins.open", mock_open(read_data=simple_config_with_invalid_entry_3)) as mock_file: + self.assertRaises( + InvalidPICSConfigurationValueError, PICSChecker, mock_file) + + with patch("builtins.open", mock_open(read_data=simple_config_with_invalid_entry_4)) as mock_file: + self.assertRaises(InvalidPICSConfigurationError, + PICSChecker, mock_file) + + with patch("builtins.open", mock_open(read_data=simple_config_with_invalid_entry_5)) as mock_file: + self.assertRaises(InvalidPICSConfigurationError, + PICSChecker, mock_file) + + with patch("builtins.open", mock_open(read_data=simple_config_with_invalid_entry_6)) as mock_file: + self.assertRaises(InvalidPICSConfigurationError, + PICSChecker, mock_file) + + +if __name__ == '__main__': + unittest.main()