diff --git a/criteria/CHANGELOG.md b/criteria/CHANGELOG.md index c8622eb..08c20fb 100644 --- a/criteria/CHANGELOG.md +++ b/criteria/CHANGELOG.md @@ -63,6 +63,8 @@ Behaviour differences to be aware of when adopting: ### Added - `build_quantity_criteria_parser(reserved_expressions, token_list=, productions=)` parses criteria on physical quantities (`response matches answer`, `dimension(response) matches dimension(answer)`, and `=`, `<=`, `>=`, `<`, `>` between quantities). Its defaults are `quantity_grammar.QUANTITY_TOKENS` / `QUANTITY_PRODUCTIONS`. It was `generate_criteria_parser` in compareExpressions' physical-quantity context, and its parse trees are unchanged. It takes the same arguments as `build_criteria_parser`, so either can fill the same slot. `reserved_expressions` isn't used yet: only `response` and `answer` scan as quantities. +- `split_criteria(criteria)` splits a string of criteria on the commas outside `()`, `[]` and `{}`, and strips each criterion. It was `split_criteria` in compareExpressions' `evaluation.py`. Unlike that version, it raises `CriteriaSyntaxError` (new, a `CriteriaError` and `ValueError`) when a bracket is closed before it is opened, or left open. The old version stopped splitting at the first such bracket instead. +- `where_substitutions(substitutions)` gives `[(name, expression), ...]` for the substitution part of a `where` criterion (the second child of a `WHERE` node), with the expressions as text. It replaces the node-reading half of compareExpressions' `parse_where_substitutions`; the caller now parses the expressions, so this package doesn't need `expression_parsing`. The grammar also accepts other relations after `where` (`where x > 2`, `where x proportional to y`). compareExpressions' `where_substitution_nodes` returned the node itself for these, instead of a list, and this function raises `CriteriaSyntaxError` for them. A `SEPARATOR` that mixes in such a relation (`where a = 2; x > 3`) used to be read as substitutions, and also raises now. ### Changed diff --git a/criteria/src/lambdafeedback/criteria/__init__.py b/criteria/src/lambdafeedback/criteria/__init__.py index 9427f01..9d32edc 100644 --- a/criteria/src/lambdafeedback/criteria/__init__.py +++ b/criteria/src/lambdafeedback/criteria/__init__.py @@ -1,7 +1,10 @@ """Criteria parser and criteria graphs for Lambda Feedback evaluation functions. - :func:`build_criteria_parser` parses criteria such as - ``response = answer where a = 2``. + ``response = answer where a = 2``; :func:`split_criteria` splits a + comma-separated string of criteria into them, and + :func:`where_substitutions` reads the substitutions of a ``where`` + criterion. - :func:`build_quantity_criteria_parser` parses criteria on physical quantities, such as ``response matches answer``. - :class:`CriteriaGraph` links evaluations to the criteria they establish, @@ -14,7 +17,7 @@ import logging from importlib.metadata import PackageNotFoundError, version -from .errors import CriteriaError, CriteriaEvaluationError, CriteriaGraphError +from .errors import CriteriaError, CriteriaEvaluationError, CriteriaGraphError, CriteriaSyntaxError from .grammar import build_criteria_parser from .graph import CriteriaGraph from .nodes import ( @@ -29,7 +32,9 @@ ResultLike, ) from .quantity_grammar import build_quantity_criteria_parser +from .split import split_criteria from .tree import CriteriaTree +from .where import where_substitutions # Library convention: callers decide where log records go (``logging.basicConfig(level=logging.DEBUG)`` shows them). logging.getLogger(__name__).addHandler(logging.NullHandler()) @@ -44,6 +49,7 @@ "CriteriaEvaluationError", "CriteriaGraph", "CriteriaGraphError", + "CriteriaSyntaxError", "CriteriaTree", "CriterionNode", "Edge", @@ -57,4 +63,6 @@ "__version__", "build_criteria_parser", "build_quantity_criteria_parser", + "split_criteria", + "where_substitutions", ] diff --git a/criteria/src/lambdafeedback/criteria/errors.py b/criteria/src/lambdafeedback/criteria/errors.py index 79cf66b..7f92538 100644 --- a/criteria/src/lambdafeedback/criteria/errors.py +++ b/criteria/src/lambdafeedback/criteria/errors.py @@ -9,6 +9,10 @@ class CriteriaGraphError(CriteriaError, ValueError): """A criteria graph was built or queried inconsistently.""" +class CriteriaSyntaxError(CriteriaError, ValueError): + """A criteria string is not well formed.""" + + class CriteriaEvaluationError(CriteriaError): """An evaluation node's ``evaluate`` function failed; the cause is chained.""" diff --git a/criteria/src/lambdafeedback/criteria/split.py b/criteria/src/lambdafeedback/criteria/split.py new file mode 100644 index 0000000..5025230 --- /dev/null +++ b/criteria/src/lambdafeedback/criteria/split.py @@ -0,0 +1,35 @@ +"""Splitting a string of criteria, such as ``response = answer, response > 0``, into criteria.""" + +from __future__ import annotations + +from .errors import CriteriaSyntaxError + +_OPENING = {")": "(", "]": "[", "}": "{"} +"""The opening bracket for each closing bracket.""" + + +def split_criteria(criteria: str) -> list[str]: + """``criteria`` split on the commas outside brackets, with each criterion stripped. + + Each kind of bracket (``()``, ``[]``, ``{}``) is counted on its own. Raises + :class:`CriteriaSyntaxError` if a bracket is closed before it is opened, + or left open. + """ + depth = dict.fromkeys(_OPENING.values(), 0) + parts = [] + start = 0 + for index, char in enumerate(criteria): + if char in depth: + depth[char] += 1 + elif char in _OPENING: + opening = _OPENING[char] + if depth[opening] == 0: + raise CriteriaSyntaxError(f"`{char}` at position {index} closes no `{opening}` in: {criteria}") + depth[opening] -= 1 + elif char == "," and not any(depth.values()): + parts.append(criteria[start:index].strip()) + start = index + 1 + if any(depth.values()): + raise CriteriaSyntaxError(f"Unclosed bracket in: {criteria}") + parts.append(criteria[start:].strip()) + return parts diff --git a/criteria/src/lambdafeedback/criteria/where.py b/criteria/src/lambdafeedback/criteria/where.py new file mode 100644 index 0000000..7094c3b --- /dev/null +++ b/criteria/src/lambdafeedback/criteria/where.py @@ -0,0 +1,29 @@ +"""The substitutions of a ``where`` criterion, such as ``response = answer where a = 2; b = 3``.""" + +from __future__ import annotations + +from lambdafeedback.slr_parsing import ExprNode + +from .errors import CriteriaSyntaxError + + +def _substitution(node: ExprNode) -> tuple[str, str]: + """``(name, expression)`` for a substitution such as ``a = 2``.""" + if node.label != "EQUALITY": + raise CriteriaSyntaxError(f"`{node.content_string()}` is not a substitution such as `a = 2`.") + name, expression = node.children + return name.content_string(), expression.content_string() + + +def where_substitutions(substitutions: ExprNode) -> list[tuple[str, str]]: + """``[(name, expression), ...]`` for the substitution part of a ``where`` criterion. + + ``substitutions`` is the second child of a ``WHERE`` node from + :func:`.build_criteria_parser`: one ``EQUALITY`` or a ``SEPARATOR`` of + them. The expressions are returned as text, for the caller to parse. + Raises :class:`CriteriaSyntaxError` for any other relation, such as + ``where x > 2``. + """ + if substitutions.label == "SEPARATOR": + return [_substitution(node) for node in substitutions.children] + return [_substitution(substitutions)] diff --git a/criteria/tests/test_criteria.py b/criteria/tests/test_criteria.py index 85d6ba1..e6daf20 100644 --- a/criteria/tests/test_criteria.py +++ b/criteria/tests/test_criteria.py @@ -12,8 +12,11 @@ from lambdafeedback.criteria import ( CriteriaGraph, CriteriaGraphError, + CriteriaSyntaxError, build_criteria_parser, build_quantity_criteria_parser, + split_criteria, + where_substitutions, ) from lambdafeedback.slr_parsing import append_last, create_node, infix, join, proceed @@ -80,6 +83,55 @@ def test_other_text_is_a_quantity(self): ] +class TestSplitCriteria: + @pytest.mark.parametrize( + "criteria, expected", + [ + ("response = answer", ["response = answer"]), + (" response = answer ,response > 0 ", ["response = answer", "response > 0"]), + ("response = f(x, y), response > 0", ["response = f(x, y)", "response > 0"]), + ("response = [1, 2], response = {a, b}", ["response = [1, 2]", "response = {a, b}"]), + ("response = f([x, {y, z}]), answer > 0", ["response = f([x, {y, z}])", "answer > 0"]), + ("response = answer,", ["response = answer", ""]), + ("", [""]), + ], + ) + def test_splits_on_commas_outside_brackets(self, criteria, expected): + assert split_criteria(criteria) == expected + + @pytest.mark.parametrize( + "criteria", ["response = x), answer > 0", "response = (x, answer > 0", "response = (x], y"] + ) + def test_unbalanced_brackets_raise(self, criteria): + with pytest.raises(CriteriaSyntaxError): + split_criteria(criteria) + + +class TestWhereSubstitutions: + @pytest.mark.parametrize( + "criterion, expected", + [ + ("response = answer where a = 2", [("a", "2")]), + ("response = answer where a = 2; b = x+1", [("a", "2"), ("b", "x+1")]), + ("response = answer where a = 2; b = 3; c = 4", [("a", "2"), ("b", "3"), ("c", "4")]), + ], + ) + def test_substitutions(self, criterion, expected): + assert where_substitutions(_parse(criterion).children[1]) == expected + + @pytest.mark.parametrize( + "criterion", + [ + "response = answer where x > 2", + "response = answer where x proportional to y", + "response = answer where a = 2; x > 3", + ], + ) + def test_other_relations_raise(self, criterion): + with pytest.raises(CriteriaSyntaxError): + where_substitutions(_parse(criterion).children[1]) + + class TestCriteriaGraph: def test_build_and_serialise(self): g = CriteriaGraph("root")