From b117eacf52df53bb748601a411ab3702ee4a189e Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Mon, 21 Sep 2026 09:57:18 +0100 Subject: [PATCH] implement: Read a comma inside a quoted selector pattern as part of the pattern (t57) --- docs/source/spec.md | 4 ++++ in2lambda/spec/__init__.py | 12 +++++++----- tests/test_spec.py | 37 +++++++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/docs/source/spec.md b/docs/source/spec.md index e344218..085ec38 100644 --- a/docs/source/spec.md +++ b/docs/source/spec.md @@ -84,6 +84,10 @@ any block. A constraint names one of four attributes: SELECTOR,` requires the block to follow the first block that the named selector matches, which is how a spec tells the solutions at the end of a problem sheet from the questions at the front. +A comma separates the `after` clause from the rest of the selector only outside quotes. A comma +inside a quoted pattern, such as `Para text~'^(Sheet|Note),|^Marks'`, is part of the pattern. One +line holds one selector: write two selectors as a list under the key, as above. + Write a regular expression in single quotes. YAML reads `\(` inside double quotes as an escape sequence and reports an error, and single quotes pass the backslash through. diff --git a/in2lambda/spec/__init__.py b/in2lambda/spec/__init__.py index 621e100..708d9d0 100644 --- a/in2lambda/spec/__init__.py +++ b/in2lambda/spec/__init__.py @@ -294,16 +294,18 @@ def _selector(text: Any, line: int) -> Selector: if not isinstance(text, str): raise _refuse(line, f"A selector is a line of text, which {text!r} is not.") head, tail = _split(text.strip()) - if head.strip().startswith("after "): - after = _clause(head.strip()[len("after ") :], line) + head = head.strip() + if head.startswith("after "): + after = _clause(head[len("after ") :], line) return _clause(tail.strip() if tail else "", line, after) if tail is not None: raise _refuse( line, - "A selector's comma separates its `after` clause from the rest, and " - f"{text!r} holds no `after` clause.", + f"{text.strip()!r} names two selectors on one line; write them as a YAML " + f"list under the role:\n - {head}\n - {tail.strip()}\n" + "A selector's comma separates its `after` clause from the rest.", ) - return _clause(head.strip(), line) + return _clause(head, line) def _selectors(value: Any, line: int, item_lines: list[int]) -> list[Selector]: diff --git a/tests/test_spec.py b/tests/test_spec.py index 191d68e..9e18e79 100644 --- a/tests/test_spec.py +++ b/tests/test_spec.py @@ -21,6 +21,7 @@ import in2lambda import in2lambda.draft +import in2lambda.spec from in2lambda.main import cli from in2lambda.source import SourceError @@ -386,6 +387,42 @@ def test_a_spec_calling_a_predicate_nothing_holds_is_refused( assert (tmp_path / "source.draft.json").read_bytes() == written +def test_a_comma_inside_a_quoted_pattern_is_part_of_the_pattern() -> None: + """A regex alternating over labels holds commas, which are not the after separator.""" + spec = in2lambda.spec.load( + "ignore: Para text~'^(Sheet|Note),|^Marks'\n" + "question: Para\n" + "layout: PartsOneSol\n" + ) + + assert spec.ignore[0].constraints[0].wanted.pattern == "^(Sheet|Note),|^Marks" + + +def test_two_selectors_written_on_one_line_are_refused_with_the_list_to_write() -> None: + """A comma outside quotes reads as an after clause, which two block types are not.""" + with pytest.raises(in2lambda.spec.BadSpec) as refusal: + in2lambda.spec.load( + "ignore: Header, Table\nquestion: Para\nlayout: PartsOneSol\n" + ) + + assert "names two selectors on one line" in str(refusal.value) + assert "- Header" in str(refusal.value) + assert "- Table" in str(refusal.value) + + +def test_a_comma_outside_quotes_still_separates_an_after_clause() -> None: + """The documented ``after SELECTOR, rest`` form, which the quotes must not break.""" + spec = in2lambda.spec.load( + "question: Para\n" + "solution: after Header text=Solutions, Para\n" + "layout: PartsOneSol\n" + ) + + assert spec.solution[0].after.type == "Header" + assert spec.solution[0].after.constraints[0].wanted == "Solutions" + assert spec.solution[0].type == "Para" + + def test_a_spec_that_is_not_there_is_refused_rather_than_left_to_the_file_system( tmp_path: Path, monkeypatch ) -> None: