Skip to content

Commit

Permalink
[matter_yamltests] Add PICS checker (#24525)
Browse files Browse the repository at this point in the history
  • Loading branch information
vivien-apple authored Jan 21, 2023
1 parent 650b5d7 commit 62cd0b7
Show file tree
Hide file tree
Showing 4 changed files with 389 additions and 20 deletions.
6 changes: 5 additions & 1 deletion scripts/py_matter_yamltests/BUILD.gn
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 31 additions & 19 deletions scripts/py_matter_yamltests/matter_yamltests/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'])
Expand All @@ -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')
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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(
Expand All @@ -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
185 changes: 185 additions & 0 deletions scripts/py_matter_yamltests/matter_yamltests/pics_checker.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 62cd0b7

Please sign in to comment.