diff --git a/src/ahbicht/expressions/ahb_expression_parser.py b/src/ahbicht/expressions/ahb_expression_parser.py index 2f079752..d89c6abd 100644 --- a/src/ahbicht/expressions/ahb_expression_parser.py +++ b/src/ahbicht/expressions/ahb_expression_parser.py @@ -32,9 +32,8 @@ def parse_ahb_expression_to_single_requirement_indicator_expressions(ahb_express PREFIX_OPERATOR: "X"i | "O"i | "U"i MODAL_MARK: /M(uss)?|S(oll)?|K(ann)?/i // Matches if it looks like a condition expression, but does not yet check if it is a syntactically valid one: - CONDITION_EXPRESSION: /(?!\BU\B)[\[\]\(\)U∧O∨X⊻\d\sP]+/i + CONDITION_EXPRESSION: /(?!\BU\B)[\[\]\(\)U∧O∨X⊻\d\sP\.]+/i """ - # todo: implement wiederholbarkeiten # Regarding the negative lookahead in the condition expression regex see examples https://regex101.com/r/6fFHD4/1 # and CTRL+F for "Mus[2]" in the unittest that fails if you remove the lookahead. parser = Lark(grammar, start="ahb_expression") diff --git a/src/ahbicht/expressions/condition_expression_parser.py b/src/ahbicht/expressions/condition_expression_parser.py index df5136c3..2453b743 100644 --- a/src/ahbicht/expressions/condition_expression_parser.py +++ b/src/ahbicht/expressions/condition_expression_parser.py @@ -26,7 +26,7 @@ def parse_condition_expression_to_tree(condition_expression: str) -> Tree[Token] :return parsed_tree: Tree """ - grammar = """ + grammar = r""" ?expression: expression "O"i expression -> or_composition | expression "∨" expression -> or_composition | expression "X"i expression -> xor_composition @@ -38,15 +38,15 @@ def parse_condition_expression_to_tree(condition_expression: str) -> Tree[Token] | package | condition ?brackets: "(" expression ")" - package: "[" PACKAGE_KEY "]" // a rule for packages + package: "[" PACKAGE_KEY REPEATABILITY? "]" // a rule for packages condition: "[" CONDITION_KEY "]" // a rule for condition keys CONDITION_KEY: INT // a TERMINAL for all the remaining ints (lower priority) + REPEATABILITY: /\d+\.{2}[1-9]\d*/ // a terminal for repetitions n..m with n>=0 and m>n PACKAGE_KEY: INT "P" // a TERMINAL for all INTs followed by "P" (high priority) %import common.INT %import common.WS %ignore WS // WS = whitespace """ - # todo: add wiederholbarkeiten https://github.com/Hochfrequenz/ahbicht/issues/96 parser = Lark(grammar, start="expression") try: parsed_tree = parser.parse(condition_expression) @@ -54,13 +54,12 @@ def parse_condition_expression_to_tree(condition_expression: str) -> Tree[Token] raise SyntaxError( """Please make sure that: * all conditions have the form [INT] - * all packages have the form [INTP] + * all packages have the form [INTPn..m] * no conditions are empty * all compositions are combined by operators 'U'/'O'/'X' or without an operator * all open brackets are closed again and vice versa """ ) from eof - # todo: implement wiederholbarkeiten return parsed_tree diff --git a/src/ahbicht/expressions/expression_resolver.py b/src/ahbicht/expressions/expression_resolver.py index be4c3190..65b75b55 100644 --- a/src/ahbicht/expressions/expression_resolver.py +++ b/src/ahbicht/expressions/expression_resolver.py @@ -1,11 +1,11 @@ """ This module makes it possible to parse expressions including all their subexpressions, if present. for example ahb_expressions which contain condition_expressions or condition_expressions which contain packages. -Parsing expressions that are nested into other expressions is refered to as "resolving". +Parsing expressions that are nested into other expressions is referred to as "resolving". """ import asyncio import inspect -from typing import Awaitable, List, Union +from typing import Awaitable, List, Optional, Union import inject from lark import Token, Transformer, Tree @@ -14,6 +14,7 @@ from ahbicht.expressions.ahb_expression_parser import parse_ahb_expression_to_single_requirement_indicator_expressions from ahbicht.expressions.condition_expression_parser import parse_condition_expression_to_tree from ahbicht.expressions.package_expansion import PackageResolver +from ahbicht.mapping_results import Repeatability, parse_repeatability async def parse_expression_including_unresolved_subexpressions( @@ -101,11 +102,26 @@ def package(self, tokens: List[Token]) -> Awaitable[Tree]: """ try to resolve the package using the injected PackageResolver """ - return self._package_async(tokens) - - async def _package_async(self, tokens: List[Token]) -> Tree[Token]: - resolved_package = await self._resolver.get_condition_expression(tokens[0].value) + # The grammar guarantees that there is always exactly 1 package_key token/terminal. + # But the repeatability token is optional, so the list repeatability_tokens might contain 0 or 1 entries + # They all come in the same `tokens` list which we split in the following two lines. + package_key_token = [t for t in tokens if t.type == "PACKAGE_KEY"][0] + repeatability_tokens = [t for t in tokens if t.type == "REPEATABILITY"] + # pylint: disable=unused-variable + # we parse the repeatability, but we don't to anything with it, yet. + repeatability: Optional[Repeatability] + if len(repeatability_tokens) == 1: + repeatability = parse_repeatability(repeatability_tokens[0].value) + else: + repeatability = None + return self._package_async(package_key_token) + + async def _package_async(self, package_key_token: Token) -> Tree[Token]: + resolved_package = await self._resolver.get_condition_expression(package_key_token.value) if not resolved_package.has_been_resolved_successfully(): - raise NotImplementedError(f"The package '{tokens[0].value}' could not be resolved by {self._resolver}") + raise NotImplementedError( + f"The package '{package_key_token.value}' could not be resolved by {self._resolver}" + ) # the package_expression is not None because that's the definition of "has been resolved successfully" - return parse_condition_expression_to_tree(resolved_package.package_expression) # type:ignore[arg-type] + tree_result = parse_condition_expression_to_tree(resolved_package.package_expression) # type:ignore[arg-type] + return tree_result diff --git a/src/ahbicht/expressions/package_expansion.py b/src/ahbicht/expressions/package_expansion.py index 0a47573c..f2ad4fdd 100644 --- a/src/ahbicht/expressions/package_expansion.py +++ b/src/ahbicht/expressions/package_expansion.py @@ -5,12 +5,12 @@ from abc import ABC, abstractmethod from typing import Mapping, Optional -# pylint:disable=too-few-public-methods from maus.edifact import EdifactFormat, EdifactFormatVersion from ahbicht.mapping_results import PackageKeyConditionExpressionMapping +# pylint:disable=too-few-public-methods class PackageResolver(ABC): """ A package resolver provides condition expressions for given package keys. diff --git a/src/ahbicht/mapping_results.py b/src/ahbicht/mapping_results.py index 440a19c7..3b755545 100644 --- a/src/ahbicht/mapping_results.py +++ b/src/ahbicht/mapping_results.py @@ -1,7 +1,8 @@ """ This module contains classes that are returned by mappers, meaning they contain a mapping. """ -from typing import Optional +import re +from typing import Match, Optional import attrs from marshmallow import Schema, fields, post_load @@ -88,3 +89,58 @@ def deserialize(self, data, **kwargs) -> PackageKeyConditionExpressionMapping: Converts the barely typed data dictionary into an actual :class:`.PackageKeyConditionExpressionMapping` """ return PackageKeyConditionExpressionMapping(**data) + + +# pylint:disable=unused-argument +def check_max_greater_or_equal_than_min(instance: "Repeatability", attribute, value): + """ + assert that 0<=min bool: + """ + returns true if the package used together with this repeatability is optional + """ + return self.min_occurrences == 0 + + +_repeatability_pattern = re.compile(r"^(?P\d+)\.{2}(?P\d+)$") #: a pattern to match "n..m" repeatabilities + + +def parse_repeatability(repeatability_string: str) -> Repeatability: + """ + parses the given string as repeatability; f.e. `17..23` is parsed as min=17, max=23 + """ + match: Optional[Match[str]] = _repeatability_pattern.match(repeatability_string) + if match is None: + raise ValueError(f"The given string '{repeatability_string}' could not be parsed as repeatability") + min_repeatability = int(match["min"]) + max_repeatability = int(match["max"]) + return Repeatability(min_occurrences=min_repeatability, max_occurrences=max_repeatability) diff --git a/unittests/test_ahb_expression_evaluation.py b/unittests/test_ahb_expression_evaluation.py index 60c4ed82..552505a0 100644 --- a/unittests/test_ahb_expression_evaluation.py +++ b/unittests/test_ahb_expression_evaluation.py @@ -162,7 +162,7 @@ def side_effect_rc_evaluation(condition_expression): SyntaxError, """Please make sure that: * all conditions have the form [INT] - * all packages have the form [INTP] + * all packages have the form [INTPn..m] * no conditions are empty * all compositions are combined by operators 'U'/'O'/'X' or without an operator * all open brackets are closed again and vice versa diff --git a/unittests/test_condition_parser.py b/unittests/test_condition_parser.py index 2eeebaea..cf759818 100644 --- a/unittests/test_condition_parser.py +++ b/unittests/test_condition_parser.py @@ -449,6 +449,23 @@ def test_parse_valid_expression_to_tree_with_format_constraints(self, expression ], ), ), + pytest.param( + # nested brackets + "[10P1..5]U([1]O[2])", + Tree( + "and_composition", + [ + Tree(Token("RULE", "package"), [Token("PACKAGE_KEY", "10P"), Token("REPEATABILITY", "1..5")]), + Tree( + "or_composition", + [ + Tree(Token("RULE", "condition"), [Token("CONDITION_KEY", "1")]), + Tree(Token("RULE", "condition"), [Token("CONDITION_KEY", "2")]), + ], + ), + ], + ), + ), ], ) def test_parse_valid_expression_with_brackets_to_tree(self, expression: str, expected_tree: Tree): @@ -484,7 +501,7 @@ def test_parse_invalid_expression(self, expression: str): assert """Please make sure that: * all conditions have the form [INT] - * all packages have the form [INTP] + * all packages have the form [INTPn..m] * no conditions are empty * all compositions are combined by operators 'U'/'O'/'X' or without an operator * all open brackets are closed again and vice versa diff --git a/unittests/test_expression_resolver.py b/unittests/test_expression_resolver.py index 7962e300..a8e6eed1 100644 --- a/unittests/test_expression_resolver.py +++ b/unittests/test_expression_resolver.py @@ -102,7 +102,7 @@ async def test_expression_resolver_failing(self, expression: str): assert """Please make sure that: * all conditions have the form [INT] - * all packages have the form [INTP] + * all packages have the form [INTPn..m] * no conditions are empty * all compositions are combined by operators 'U'/'O'/'X' or without an operator * all open brackets are closed again and vice versa diff --git a/unittests/test_package_resolver.py b/unittests/test_package_resolver.py index a79330f9..8ea1a58c 100644 --- a/unittests/test_package_resolver.py +++ b/unittests/test_package_resolver.py @@ -36,6 +36,9 @@ def inject_package_resolver(self, request: SubRequest): "unexpanded_expression, expected_expanded_expression", [ pytest.param("[123P]", "[1] U ([2] O [3])"), + pytest.param("[123P7..8]", "[1] U ([2] O [3])"), + pytest.param("[123P10..11]", "[1] U ([2] O [3])"), + pytest.param("[123P0..5]", "[1] U ([2] O [3])"), pytest.param("[17] U [123P]", "[17] U ([1] U ([2] O [3]))"), ], ) @@ -47,3 +50,22 @@ async def test_correct_injection( assert actual_tree is not None expected_expanded_tree = parse_condition_expression_to_tree(expected_expanded_expression) assert actual_tree == expected_expanded_tree + + @pytest.mark.parametrize( + "inject_package_resolver", + [{"123P": "[1] U ([2] O [3])"}], + indirect=True, + ) + @pytest.mark.parametrize( + "unexpanded_expression, error_message", + [ + pytest.param("[123P8..7]", "0≤n≤m is not fulfilled for n=8, m=7"), + ], + ) + async def test_invalid_package_repeatability( + self, inject_package_resolver, unexpanded_expression: str, error_message: str + ): + unexpanded_tree = parse_condition_expression_to_tree(unexpanded_expression) + with pytest.raises(ValueError) as invalid_repeatability_error: + _ = await expand_packages(parsed_tree=unexpanded_tree) + assert error_message in str(invalid_repeatability_error) diff --git a/unittests/test_requirement_constraint_evaluation.py b/unittests/test_requirement_constraint_evaluation.py index d693c0a6..ff0389af 100644 --- a/unittests/test_requirement_constraint_evaluation.py +++ b/unittests/test_requirement_constraint_evaluation.py @@ -111,7 +111,7 @@ async def test_evaluate_valid_ahb_expression( SyntaxError, """Please make sure that: * all conditions have the form [INT] - * all packages have the form [INTP] + * all packages have the form [INTPn..m] * no conditions are empty * all compositions are combined by operators 'U'/'O'/'X' or without an operator * all open brackets are closed again and vice versa diff --git a/unittests/test_utility_functions.py b/unittests/test_utility_functions.py index 7633d14e..a8986225 100644 --- a/unittests/test_utility_functions.py +++ b/unittests/test_utility_functions.py @@ -5,6 +5,7 @@ import pytest # type:ignore[import] +from ahbicht.mapping_results import Repeatability, parse_repeatability from ahbicht.utility_functions import gather_if_necessary pytestmark = pytest.mark.asyncio @@ -36,3 +37,31 @@ class TestUtilityFunctions: async def test_gather_if_necessary(self, mixed_input: List[Union[T, Awaitable[T]]], expected_result: List[T]): actual = await gather_if_necessary(mixed_input) assert actual == expected_result + + @pytest.mark.parametrize( + "candidate, expected_result", + [ + pytest.param("0..1", Repeatability(min_occurrences=0, max_occurrences=1)), + pytest.param("1..1", Repeatability(min_occurrences=1, max_occurrences=1)), + pytest.param("1..2", Repeatability(min_occurrences=1, max_occurrences=2)), + pytest.param("71..89", Repeatability(min_occurrences=71, max_occurrences=89)), + ], + ) + def test_parse_repeatability(self, candidate: str, expected_result: Repeatability): + actual = parse_repeatability(candidate) + assert actual == expected_result + + @pytest.mark.parametrize( + "invalid_candidate", + [ + pytest.param("1..0"), + pytest.param("77..76"), + pytest.param("0..0"), + pytest.param("-1..1"), + pytest.param("bullshit"), + pytest.param(""), + ], + ) + def test_parse_repeatability_failures(self, invalid_candidate): + with pytest.raises(ValueError): + _ = parse_repeatability(invalid_candidate)