From 772f424e2be34d5f0d98d091519471e8ea1ba1b9 Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" Date: Sun, 20 Sep 2026 23:28:18 +0100 Subject: [PATCH 1/3] implement: Export the solution environment convert's own example uses (t52) --- CHANGELOG.md | 1 + in2lambda/filters/PartsOneSol/filter.py | 13 ++++++++----- tests/test_runner.py | 26 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85f7cc3..579869e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,5 +17,6 @@ - An export now names its images as they sit in `media/`: every markdown image reference a question holds - in its text, a part's, a worked solution, a final answer or an answer box's wording - is rewritten to the file name the image was carried under, so a document writing `![](figures/train.png)` exports as `![](train.png)` beside `media/train.png` and Lambda Feedback finds the figure where it looks for one. A file two questions use is carried once; where two different files are called the same, the second is named as Lambda Feedback's own exports name an image, `question_001__0001.png`. Reading an export back is unchanged, since an export already names its images this way. - A draft can freeze more than one document, which is how a sheet written as a question file and a separate solutions file is drafted: `in2lambda source add questions.docx solutions.docx` freezes them as source 1 and source 2 of the one `questions.draft.json`, and `in2lambda source add solutions.docx --draft questions.docx` adds a file to a draft already written as its next source. Every block id and line range of a source after the first carries its number - `2/b3`, `2/s10:14`, with `1/b3` meaning the `b3` it always did - and a field quoted from one records which source it came from, so that the same line number in two documents is two different places. `in2lambda source show` prints each source under its number and its name. `in2lambda spec run` runs the spec over every source: the first is laid out as its `layout` says, and in any source after it the `question` selector picks out the marker written above each question's solutions while everything else the spec picks out is a solution, paired onto the questions and parts of the first the way `in2lambda convert -a` pairs an answers file. A draft now holds `sources`, a list of `{source, hash, blocks}` in the order they were frozen, rather than those three at the top level, so a draft written before this is refused as one nothing here wrote; `in2lambda source add --start-over` freezes the document again. In the Python API, `in2lambda.source.add` takes a list of files and the draft to freeze them into; `in2lambda.source.frozen` and `in2lambda.draft.apply` hand back and take the markdown of every source rather than of one; `in2lambda.spec.fields` takes one `(blocks, markdown)` pair per source in place of its `elements` and `markdown` arguments; and `in2lambda.spec.Field` carries the number of the source its ranges are lines of, which anything constructing one has to say. - Every command that works on a draft - `in2lambda source show`, each of the `in2lambda draft` commands, `in2lambda spec run`, `in2lambda validate`, `in2lambda build` and `in2lambda render` - takes `--draft`, naming either the draft or the source it was frozen from. Left off, it uses the one draft in the current directory, and where there is more than one it is refused naming them rather than acting on whichever sorts first. `in2lambda spec run` names its SPEC from the draft's directory. The Python functions behind them take the draft's path rather than a directory: `in2lambda.source.frozen`, `in2lambda.source.show`, `in2lambda.draft.execute`, `in2lambda.draft.replay`, `in2lambda.draft.spec_command`, `in2lambda.draft.report.validate`, `in2lambda.draft.export.build` and `in2lambda.draft.export.render`. `in2lambda.source.draft_of` says where a document's draft goes and `in2lambda.source.find` is what the command line resolves `--draft` with. +- `in2lambda convert FILE PartsOneSol` now exports the worked solution a document writes in a `solution` environment. Pandoc writes that environment as a Div whose classes hold `solution`, and the filter recognised only a Div whose first block reads `Solution`, so a document using the environment exported every question with an empty worked solution. A Div whose first block reads `Solution` is still recognised. - Importing `in2lambda.katex_convert` no longer writes a file called `log` into the working directory. What it has to say about a converted expression goes to the `in2lambda.katex_convert` logger, which is silent unless the application configures logging. - The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects. diff --git a/in2lambda/filters/PartsOneSol/filter.py b/in2lambda/filters/PartsOneSol/filter.py index efe685f..e4b4be8 100755 --- a/in2lambda/filters/PartsOneSol/filter.py +++ b/in2lambda/filters/PartsOneSol/filter.py @@ -49,11 +49,14 @@ def pandoc_filter( for item in elem.content: set.current_question.add_part_text(item) - # Solution is in a Div with nested content being "Solution" + # Pandoc writes a LaTeX environment it has no block for as a Div whose classes + # hold the environment's name, so \begin{solution} becomes a Div classed + # "solution". Some documents instead write the word Solution as the first block + # of the Div; the filter accepts both. case pf.Div: - if pf.stringify(elem.content[0].content) == "Solution": - set.current_question.add_solution( - pf.stringify(elem) # [len("Solution") :] - For Jon Rackham - ) + if "solution" in elem.classes or ( + elem.content and pf.stringify(elem.content[0].content) == "Solution" + ): + set.current_question.add_solution(pf.stringify(elem)) return None diff --git a/tests/test_runner.py b/tests/test_runner.py index a7d30f0..585858b 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -36,6 +36,32 @@ def test_runner_returns_populated_set(filter_name: str, filters_dir: str) -> Non assert question.main_text or question.parts +@pytest.mark.parametrize("filter_name", builtin_filters()) +def test_runner_exports_the_examples_solutions( + filter_name: str, filters_dir: str +) -> None: + """A solution written in an example is a worked solution in the exported set. + + Each example that writes a ``solution`` environment answers every question in it, so + every part carries a worked solution. An example added later that leaves a question + unanswered needs a test of its own rather than a looser assertion here. + """ + example = _example(filters_dir, filter_name) + with open(example) as source: + written_solutions = "\\begin{solution}" in source.read() + if not written_solutions: + pytest.skip(f"{filter_name}'s example writes no solution environment") + + result = runner(example, filter_name) + + for number, question in enumerate(result.questions, start=1): + assert question.parts, f"question {number} has no parts to answer" + for index, part in enumerate(question.parts, start=1): + assert ( + part.worked_solution.strip() + ), f"question {number} part {index} has no worked solution" + + @pytest.mark.parametrize("filter_name", builtin_filters()) def test_runner_writes_importable_json( filter_name: str, filters_dir: str, tmp_path From c4e221b630fec1600414cf3125ab4a18c498c9ba Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" <peterbjohnson@gmail.com> Date: Sun, 20 Sep 2026 23:50:53 +0100 Subject: [PATCH 2/3] implement: Export the solution environment convert's own example uses (t52) --- in2lambda/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/in2lambda/main.py b/in2lambda/main.py index 708334a..5b39cf1 100644 --- a/in2lambda/main.py +++ b/in2lambda/main.py @@ -109,7 +109,7 @@ def runner( >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsSepSol/example.tex", "PartsSepSol") # doctest: +ELLIPSIS Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution='', answer='', response_areas=[]), ...], images=[], main_text='This is a sample question\n\n'), ...]) >>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS - Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution='', answer='', response_areas=[]), ...], images=[...], main_text='Here is some preliminary question information that might be useful.'), ...]) + Set(_name='set', _description='', _finalAnswerVisibility='OPEN_WITH_WARNINGS', _workedSolutionVisibility='OPEN_WITH_WARNINGS', _structuredTutorialVisibility='OPEN', questions=[Question(title='', parts=[Part(text=..., worked_solution='This is the final answer...', answer='', response_areas=[]), ...], images=[...], main_text='Here is some preliminary question information that might be useful.'), ...]) """ _require_conversion_tools() import panflute as pf From 333559a82fedede385102f296bba4a181539c58f Mon Sep 17 00:00:00 2001 From: "Peter B. Johnson" <peterbjohnson@gmail.com> Date: Mon, 21 Sep 2026 00:58:20 +0100 Subject: [PATCH 3/3] implement: Export the solution environment convert's own example uses (t52) --- tests/fixtures/against_convert/PartsOneSol/differs.txt | 4 +--- tests/fixtures/against_convert/README.md | 5 ----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/fixtures/against_convert/PartsOneSol/differs.txt b/tests/fixtures/against_convert/PartsOneSol/differs.txt index e9d9fd5..c87077b 100644 --- a/tests/fixtures/against_convert/PartsOneSol/differs.txt +++ b/tests/fixtures/against_convert/PartsOneSol/differs.txt @@ -1,4 +1,2 @@ -Question 1 "", part (a), worked solution: the draft says 'This is the final answer. It contains the solutions for all parts without breaking them up. $$ 1+1 = 2 $$ The solution is copied across all parts.' and convert says '' # t52: the PartsOneSol filter drops every solution Question 1 "", part (b), text: the draft says "The filter still works even if there aren't any parts" and convert says 'The filter still works even if there aren’t any parts' # smart quotes: pandoc's LaTeX reader writes ’ where its commonmark_x writer writes ' -Question 1 "", part (b), worked solution: the draft says 'This is the final answer. It contains the solutions for all parts without breaking them up. $$ 1+1 = 2 $$ The solution is copied across all parts.' and convert says '' # t52: the PartsOneSol filter drops every solution -Question 2 "", part (a): the draft wrote this part and convert did not # t52: the PartsOneSol filter drops every solution +Question 2 "", part (a), worked solution: the draft says "And here's the solution" and convert says 'And here’s the solution' # smart quotes: pandoc's LaTeX reader writes ’ where its commonmark_x writer writes ' diff --git a/tests/fixtures/against_convert/README.md b/tests/fixtures/against_convert/README.md index fa04f16..22abd76 100644 --- a/tests/fixtures/against_convert/README.md +++ b/tests/fixtures/against_convert/README.md @@ -42,11 +42,6 @@ A folder with no such file is a document the two routes say the same thing about Finding a difference the file does not list fails the test, and so does agreeing where it lists one: closing a ticket below means deleting the lines it names. -- **t52** - the `PartsOneSol` filter matches a solution environment only when the Div's - first element stringifies to `Solution`, which pandoc writes for no solution environment, - so convert drops every worked solution in that layout. The draft writes both solutions - of `PartsOneSol/example.tex`, and the second question's solution is a part of its own - that convert has nothing to match. - **t53** - no draft command writes a `qN.pM.solution`, and no selector reaches inside the top-level `\item` that `PartPartSolSol/example.tex` and `PartSolPartSol/example.tex` nest each part's solution in, so those lines are marked ignore and the draft answers