From a0112d628b2d7d903972bf4c491be9df6356941b Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 23:39:36 +0100 Subject: [PATCH] implement: Write a part's solution by command (t53) --- CHANGELOG.md | 1 + docs/source/drafts.md | 6 + in2lambda/draft/__init__.py | 49 ++++++++ in2lambda/main.py | 23 +++- tests/fixtures/drafts/README.md | 6 + .../drafts/part_solutions/commands.json | 71 +++++++++++ .../drafts/part_solutions/expected.json | 110 ++++++++++++++++++ .../drafts/part_solutions/report.json | 14 +++ .../fixtures/drafts/part_solutions/source.md | 17 +++ tests/test_draft.py | 8 ++ 10 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/drafts/part_solutions/commands.json create mode 100644 tests/fixtures/drafts/part_solutions/expected.json create mode 100644 tests/fixtures/drafts/part_solutions/report.json create mode 100644 tests/fixtures/drafts/part_solutions/source.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f635f..072acc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - `in2lambda source add` freezes a converted .docx or .tex unwrapped, so a paragraph is one line however long it is and an inline `$ ... $` is never broken over two, and moves every `$$ ... $$` pandoc wrote on one line onto lines of its own, in a paragraph or anywhere in a list item, indented to the item's width where it stands in a list. A `$$` that opens or closes in a table cell, in a block quote or in a code block is left as written, and so is maths an unpaired `$$` elsewhere in the document - one in inline code, say - pairs with; `in2lambda validate` still reports the maths left as written. Both were habits of pandoc's writer rather than anything the author did, and neither is maths Lambda Feedback renders, so `in2lambda validate` reported them against every converted sheet. The markdown beside a document frozen before this differs, and so do its line ranges: `in2lambda source add --start-over` freezes it again. - A draft now holds a `log` of every command that changed it and a `fields` map of what those commands wrote, each field recording which layer wrote it (1 a spec, 2 a predicate, 3 a line range, 4 a literal), the source ranges it was copied from, whether it has been edited and by whom. `in2lambda draft mark ignore BLOCK` is the first such command, and `in2lambda draft replay` rebuilds the draft from the frozen markdown and the log, refusing unless what it builds is the draft that is there, byte for byte. A draft written before this has no `log` in it and is refused as one nothing here wrote; `in2lambda source add --start-over` freezes the document again. - A draft is filled in by `in2lambda draft question add`, `in2lambda draft part add QUESTION` and `in2lambda draft question solution QUESTION`. Each takes `--text` to copy the wording out of the frozen source, as a block id such as `b3` or as lines such as `s10:14`, or `--literal TEXT` where the source does not say it in a form the field can take, which records the field as edited and written by layer 4 rather than 3. Question and part numbers are worked out from the fields already written rather than given, so a replay arrives at the same ids. `in2lambda draft split block BLOCK AT` cuts a block the parser made one of two things into `b3a` and `b3b`, so that each half can be quoted on its own. A command writing a field that is already written, or quoting lines another field was taken from, is refused: the first naming the field, the second naming both. +- `in2lambda draft part solution PART --text RANGE` gives one part of a question its worked solution, for a sheet that writes a solution under each part rather than one answering the whole question. PART is written `q1.p2`, and the command writes `q1.p2.solution` from the lines named, or from `--literal TEXT`, as `in2lambda draft question solution` writes a question's. A PART that names a question, or a part the draft has not written, is refused naming it. The parts of a sheet laid out this way can now be answered by commands, which only a spec's `PartPartSolSol` and `PartSolPartSol` layouts could do before, and the warning `in2lambda validate` prints about a part nothing answers can be acted on. - `in2lambda draft field replace FIELD OLD NEW` changes the wording inside a field that is already written, for the faults only an edit can fix - a brace the OCR dropped out of some maths, which no range of the source says correctly. OLD has to occur in the field exactly once, or the command is refused saying how many times it occurs; `--regex` reads it as a regular expression and NEW as what to replace it with. The field is left quoting the lines it was taken from, at the layer that wrote it, but recorded as edited and by whoever replaced the wording, so the change can be shown against the source. - `in2lambda draft field set FIELD --text RANGE` quotes lines of a frozen source into a field that is already written, for a field quoted from the wrong lines - a spec matching the label line `Q4` alone writes an empty `q4.text`, which `in2lambda validate` reports as empty. The field is written again at layer 3, with the range of the lines quoted and `edited` false, as `in2lambda draft question add --text` writes one; the range it named before is dropped, and `in2lambda validate` reports those lines as in no field. There is no `--literal`: `in2lambda draft field replace` is the command for text no line of the source says. - `in2lambda spec run SPEC` runs a YAML file of selectors over the frozen source: it says which blocks are questions, parts and solutions, which to ignore, what to strip off the front of each one, and which of the four filters lays the solutions out. It fills in the draft's fields with the markdown of the lines each was taken from, records the spec's name and hash in the log so a replay runs the same file, and reports every block it made nothing of. Running an edited spec over a draft it has already filled in is refused, as freezing a document that has changed is: `in2lambda source add --start-over` begins the draft again. Reading a spec needs pyyaml, which the `convert` extra now installs alongside panflute. See [the spec page](https://lambda-feedback.github.io/in2lambda/spec.html) for the selectors and layouts. diff --git a/docs/source/drafts.md b/docs/source/drafts.md index af87575..b158bbd 100644 --- a/docs/source/drafts.md +++ b/docs/source/drafts.md @@ -334,6 +334,12 @@ $ in2lambda draft question solution q2 --text b8b Wrote q2.solution. ``` +This sheet writes one solution per question. A sheet that writes a solution under each part is +quoted part by part instead, by `in2lambda draft part solution PART --text RANGE`, where PART is +written `q1.p2`. It writes `q1.p2.solution`, takes `--text` and `--literal` as +`in2lambda draft question solution` does, and refuses a part that `in2lambda draft part add` has +not written. + ## Change what a field says The sheet writes the second solution with `\half`, which KaTeX does not define, and diff --git a/in2lambda/draft/__init__.py b/in2lambda/draft/__init__.py index 4ae50fa..5f1d192 100644 --- a/in2lambda/draft/__init__.py +++ b/in2lambda/draft/__init__.py @@ -51,6 +51,9 @@ _RANGE = re.compile(r"s(\d+)(?::(\d+))?") """Lines of a frozen source, as ``s16`` for one of them or ``s10:14`` for several.""" +_PART = re.compile(r"q\d+\.p\d+") +"""A part of a question, as the draft names one: ``q1.p2``.""" + _QUALIFIED = re.compile(r"(\d+)/([^/]*)") """A block id or a line range with the source it is in in front: ``2/b3``, ``2/s10:14``. @@ -80,6 +83,10 @@ class NoSuchQuestion(SourceError): """A command adds to a question nothing has written yet.""" +class NoSuchPart(SourceError): + """A command adds to a part nothing has written yet.""" + + class AlreadyFilled(SourceError): """A command would write a field that is written, or lines another field took.""" @@ -580,6 +587,23 @@ def _require_question(draft: dict[str, Any], question: str, command: str) -> Non ) +def _require_part(draft: dict[str, Any], part: str, command: str) -> None: + """Checks the draft has the part a command adds to. + + The id is checked for its shape as well as for being written, so that a question id + given where a part was asked for is refused rather than writing the field + ``question solution`` writes. + + Raises: + NoSuchPart: the id is not a part id, or nothing has written that part's text. + """ + if _PART.fullmatch(part) is None or f"{part}.text" not in draft["fields"]: + raise NoSuchPart( + f"There is no part {part} in the draft: {command} adds to a part in2lambda " + "draft part add has already written, named as q1.p2." + ) + + def _text_field(draft: dict[str, Any], key: str, command: str) -> dict[str, Any]: """The field of that name, which a command writing into one has to find. @@ -684,6 +708,31 @@ def _question_solution( ) +@command("part solution") +def _part_solution( + draft: dict[str, Any], + sources: list[str], + args: dict[str, Any], + by: str, + directory: str, +) -> str: + """Gives one part of a question its worked solution, wherever it is written. + + A sheet that writes a solution under each part is answered part by part, which is + what a spec's PartPartSolSol and PartSolPartSol layouts do in one run. + """ + part = _argument(args, "part", "part solution") + _require_part(draft, part, "part solution") + return _fill( + draft, + sources, + args, + by, + command="part solution", + key=f"{part}.solution", + ) + + @command("field replace") def _field_replace( draft: dict[str, Any], diff --git a/in2lambda/main.py b/in2lambda/main.py index 708334a..0c90c38 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -410,7 +410,7 @@ def draft_question_solution( @draft_group.group("part") def draft_part() -> None: - """Adds a part to a question of the draft.""" + """Adds a part to a question of the draft, or says where its solution is written.""" @draft_part.command("add") @@ -431,6 +431,27 @@ def draft_part_add( ) +@draft_part.command("solution") +@click.argument("part") +@_text_or_literal +@_by +@_draft +def draft_part_solution( + part: str, + text: Optional[str], + literal: Optional[str], + by: str, + draft: Optional[str], +) -> None: + """Gives PART - q1.p2 - the worked solution written at --text or --literal.""" + _run( + "part solution", + {"part": part, "text": text, "literal": literal}, + by, + draft, + ) + + @draft_group.group("split") def draft_split() -> None: """Cuts up a block of the frozen source that is really two things.""" diff --git a/tests/fixtures/drafts/README.md b/tests/fixtures/drafts/README.md index 022feaf..ba25a56 100644 --- a/tests/fixtures/drafts/README.md +++ b/tests/fixtures/drafts/README.md @@ -61,6 +61,12 @@ them with, the first question's part is typed out because the source writes the beside it, the two solutions are one block that `split block` cuts in two, and one `field replace` writes a command KaTeX defines over one it does not. The `spec.yaml` beside it is the spec that page runs before starting the draft again, and no command here runs it. +`part_solutions` is the sheet that writes a solution under each part, which the spec layouts +`PartPartSolSol` and `PartSolPartSol` read: two `part solution` commands quote the two solutions +onto `q1.p1` and `q1.p2`, a `question solution` quotes the one answering the second question, and +the third part, which the sheet answers nowhere, is the one finding in the report. Each field +quotes the block as the sheet writes it, so a solution keeps the `Solution:` the sheet labels it +with, where the spec fixture of the same document strips the label. `degrees` writes `^\circ` into the maths of both a question and the one worked solution answering its two parts, and is the one folder whose report comes from `in2lambda.validation` over the set the draft describes rather than from the checks over the draft itself: the solution is reported diff --git a/tests/fixtures/drafts/part_solutions/commands.json b/tests/fixtures/drafts/part_solutions/commands.json new file mode 100644 index 0000000..03feef2 --- /dev/null +++ b/tests/fixtures/drafts/part_solutions/commands.json @@ -0,0 +1,71 @@ +[ + { + "args": { + "block": "b1" + }, + "by": "tests", + "command": "mark ignore" + }, + { + "args": { + "text": "b2" + }, + "by": "tests", + "command": "question add" + }, + { + "args": { + "question": "q1", + "text": "b3" + }, + "by": "tests", + "command": "part add" + }, + { + "args": { + "question": "q1", + "text": "b4" + }, + "by": "tests", + "command": "part add" + }, + { + "args": { + "question": "q1", + "text": "b5" + }, + "by": "tests", + "command": "part add" + }, + { + "args": { + "part": "q1.p1", + "text": "b6" + }, + "by": "tests", + "command": "part solution" + }, + { + "args": { + "part": "q1.p2", + "text": "b7" + }, + "by": "tests", + "command": "part solution" + }, + { + "args": { + "text": "b8" + }, + "by": "tests", + "command": "question add" + }, + { + "args": { + "question": "q2", + "text": "b9" + }, + "by": "tests", + "command": "question solution" + } +] diff --git a/tests/fixtures/drafts/part_solutions/expected.json b/tests/fixtures/drafts/part_solutions/expected.json new file mode 100644 index 0000000..c8c407d --- /dev/null +++ b/tests/fixtures/drafts/part_solutions/expected.json @@ -0,0 +1,110 @@ +{ + "b1.ignore": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 1, + 1 + ] + ], + "value": true + }, + "q1.p1.solution": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 11, + 11 + ] + ], + "value": "Solution: $W = nRT\\ln(V_1/V_2)$." + }, + "q1.p1.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 5, + 5 + ] + ], + "value": "Find the work done on the gas." + }, + "q1.p2.solution": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 13, + 13 + ] + ], + "value": "Solution: $Q = W$, since the internal energy does not change." + }, + "q1.p2.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 7, + 7 + ] + ], + "value": "Find the heat rejected." + }, + "q1.p3.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 9, + 9 + ] + ], + "value": "Sketch the process on a $p$-$V$ diagram." + }, + "q1.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 3, + 3 + ] + ], + "value": "Q1. An ideal gas is compressed isothermally from $V_1$ to $V_2$." + }, + "q2.solution": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 17, + 17 + ] + ], + "value": "Solution: $\\eta = 1 - T_c/T_h = 0.5$." + }, + "q2.text": { + "by": "tests", + "edited": false, + "layer": 3, + "ranges": [ + [ + 15, + 15 + ] + ], + "value": "Q2. Find the efficiency of a Carnot engine between 300 K and 600 K." + } +} diff --git a/tests/fixtures/drafts/part_solutions/report.json b/tests/fixtures/drafts/part_solutions/report.json new file mode 100644 index 0000000..49cf153 --- /dev/null +++ b/tests/fixtures/drafts/part_solutions/report.json @@ -0,0 +1,14 @@ +[ + { + "check": "no-solution", + "field": "q1.p3", + "level": "warning", + "message": "q1.p3 (lines 9-9) has no solution: neither q1.p3.solution nor q1.solution is written.", + "ranges": [ + [ + 9, + 9 + ] + ] + } +] diff --git a/tests/fixtures/drafts/part_solutions/source.md b/tests/fixtures/drafts/part_solutions/source.md new file mode 100644 index 0000000..9f10886 --- /dev/null +++ b/tests/fixtures/drafts/part_solutions/source.md @@ -0,0 +1,17 @@ +# Thermodynamics problem sheet + +Q1. An ideal gas is compressed isothermally from $V_1$ to $V_2$. + +(a) Find the work done on the gas. + +(b) Find the heat rejected. + +(c) Sketch the process on a $p$-$V$ diagram. + +Solution: $W = nRT\ln(V_1/V_2)$. + +Solution: $Q = W$, since the internal energy does not change. + +Q2. Find the efficiency of a Carnot engine between 300 K and 600 K. + +Solution: $\eta = 1 - T_c/T_h = 0.5$. diff --git a/tests/test_draft.py b/tests/test_draft.py index d2901ba..b6f1144 100644 --- a/tests/test_draft.py +++ b/tests/test_draft.py @@ -397,6 +397,11 @@ def test_the_refusal_names_the_lines_that_are_in_the_way( (["draft", "question", "add", "--text", "s8", "--literal", "Words."], "both"), (["draft", "question", "add"], "neither"), (["draft", "part", "add", "q9", "--text", "s8"], "q9"), + (["draft", "part", "solution", "q1.p9", "--text", "s8"], "no part q1.p9"), + # A question is not a part of one, and the field it would write is q1.solution, + # which is the other command's. + (["draft", "part", "solution", "q1", "--text", "s8"], "no part q1"), + (["draft", "part", "solution", "q1.p", "--text", "s8"], "no part q1.p"), (["draft", "split", "block", "b3", "5"], "b3 is lines 5-6"), (["draft", "split", "block", "b3", "7"], "b3 is lines 5-6"), # q1.text has a $d$ and a $v$ in it, so a $ names four places and none of them. @@ -422,6 +427,9 @@ def test_the_refusal_names_the_lines_that_are_in_the_way( "a text and a literal", "no text and no literal", "a question nothing has written", + "a part nothing has written", + "a question where a part was asked for", + "a part id that is not one", "a split at the line the block starts on", "a split past the line it ends on", "wording the field says more than once",