Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions criteria/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 10 additions & 2 deletions criteria/src/lambdafeedback/criteria/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
Expand All @@ -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())
Expand All @@ -44,6 +49,7 @@
"CriteriaEvaluationError",
"CriteriaGraph",
"CriteriaGraphError",
"CriteriaSyntaxError",
"CriteriaTree",
"CriterionNode",
"Edge",
Expand All @@ -57,4 +63,6 @@
"__version__",
"build_criteria_parser",
"build_quantity_criteria_parser",
"split_criteria",
"where_substitutions",
]
4 changes: 4 additions & 0 deletions criteria/src/lambdafeedback/criteria/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
35 changes: 35 additions & 0 deletions criteria/src/lambdafeedback/criteria/split.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions criteria/src/lambdafeedback/criteria/where.py
Original file line number Diff line number Diff line change
@@ -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)]
52 changes: 52 additions & 0 deletions criteria/tests/test_criteria.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
Loading