Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
- An export now names its images by the file names `media/` holds them under. Every markdown image reference a question holds — in the question's text, a part's text, 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 have the same name, in2lambda names the second as Lambda Feedback's own exports name an image, `question_001_<Title>_0001.png`. Reading an export back is unchanged, because 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 an existing draft as its next source. Every block id and line range of a source after the first carries that source's number — `2/b3`, `2/s10:14` — and `1/b3` names the block `b3` names. A field quoted from a source records which source it came from, so that the same line number in two documents is two places. `in2lambda source show` prints each source under its number and its name. `in2lambda spec run` runs the spec over every source: the first source is laid out as the spec's `layout` says, and in any source after it the `question` selector picks out the marker written above each question's solutions while every other match is a solution, paired onto the questions and parts of the first source as `in2lambda convert -a` pairs an answers file. A draft now holds `sources`, a list of `{source, hash, blocks}` in the order they were frozen, in place of those three keys at the top level, so a draft written before this release is refused as a draft in2lambda did not write; `in2lambda source add --start-over` freezes the document again. Four changes to the Python API break existing scripts: `in2lambda.source.add` takes a list of files and the draft to freeze them into; `in2lambda.source.frozen` returns the markdown of every source and `in2lambda.draft.apply` takes the markdown of every source, in place 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 every caller constructing a `Field` must pass.
- Every command that works on a draft takes `--draft`, naming either the draft or the source it was frozen from: `in2lambda source show`, each `in2lambda draft` command, `in2lambda spec run`, `in2lambda validate`, `in2lambda build` and `in2lambda render`. Left off, each command uses the one draft in the current directory, and where the directory holds more than one draft, the command is refused, naming them. `in2lambda spec run` resolves its SPEC from the draft's directory. The Python functions behind those commands take the draft's path in place of 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` returns the path of a document's draft, and `in2lambda.source.find` resolves `--draft` for the command line.
- `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. That module reports what it changed in an expression to the `in2lambda.katex_convert` logger, which is silent unless the application configures logging.
- `in2lambda convert` now reads a .docx that holds an image. in2lambda looks in the document for the directories a `\graphicspath` names, and read the document as UTF-8 text to find them. A .docx is a zip file, so converting a Word document holding a figure raised `UnicodeDecodeError`. in2lambda now reads a document that is not UTF-8 text as naming no directory, which is what a .docx names.
- The rest of the Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects.
13 changes: 8 additions & 5 deletions in2lambda/filters/PartsOneSol/filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion in2lambda/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,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
Expand Down
4 changes: 1 addition & 3 deletions tests/fixtures/against_convert/PartsOneSol/differs.txt
Original file line number Diff line number Diff line change
@@ -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 '
5 changes: 0 additions & 5 deletions tests/fixtures/against_convert/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,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
Expand Down
Loading