Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/source/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 7 additions & 5 deletions in2lambda/spec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
37 changes: 37 additions & 0 deletions tests/test_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@

import in2lambda
import in2lambda.draft
import in2lambda.spec
from in2lambda.main import cli
from in2lambda.source import SourceError

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