Skip to content

Commit e57325b

Browse files
committed
verify: Keep one draft per source, not per directory (t42)
2 parents 999574c + 5f6fe38 commit e57325b

17 files changed

Lines changed: 372 additions & 54 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- A field quoted out of a list item is now dedented as commonmark reads the item: the marker comes off the first line and as much of the same width off every line under it. So a question written `1. ` no longer carries its number, a continuation line no longer arrives indented far enough to be rendered as a code block, and a spec's `strip` is left with what pandoc does not read as a marker. Values written by `in2lambda spec run`, `in2lambda draft question add`, `in2lambda draft part add` and `in2lambda draft question solution` change accordingly; the ranges behind them still name the same source lines.
1414
- `in2lambda validate` checks a draft over as a whole and writes what it finds into it as a `report`: source blocks in no field and not marked ignore, two fields taken from the same lines, gaps in the numbering of the questions or their parts, and fields holding nothing, each at level `error`; a part, or a question written without parts, that nothing in the draft answers is reported at level `warning` instead. Each finding names the level, the field and the lines it is about, so it can be acted on without reading the draft. Finding something is not a failure and the command still exits 0; the report is replaced by the next run of the checks and dropped by the next command that changes the draft, since it describes the draft as it stood. It also checks over the set the draft describes, as a converted document is checked at export - maths delimiters, what KaTeX will not render, images the export would not carry, and the compile Lambda Feedback's PDF generator does where pandoc and xelatex are installed, with a warning saying what to install where they are not - and reports each of those against the draft field the text is written in, at level `error`, so that `in2lambda build` refuses them as it refuses anything else at that level.
1515
- `in2lambda build` writes a draft out as a Lambda Feedback set: one question per `qN.text` field, holding the parts written for it and the worked solutions, with the images those fields refer to under `media/`, as `in2lambda convert` writes a set - a field naming an image that is not beside the draft is refused saying which file is missing, since the checks read the draft and not the folder it is in, and a question's own solution written beside a solution for every part it has becomes a part of its own holding just that solution, as `convert` pairs them up. It is refused unless `in2lambda validate` has been run since the draft last changed - every command that changes one drops its report - and found nothing at level `error`, and the refusal prints those findings so they can be acted on without opening the draft. A finding at level `warning` - a part or question nothing in the draft answers - does not stop it: half the sheets there are keep their solutions in another file or have none at all, so the warning is printed and the set written all the same, rather than a solution having to be invented to quiet it. `in2lambda render` writes each question as a PDF instead, compiled as Lambda Feedback's own PDF generator compiles it, which needs pandoc and xelatex; it is gated on nothing, since looking at a draft is how what the checks found gets fixed. Both take `-o/--out`, as `convert` does.
16+
- 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_<Title>_0001.png`. Reading an export back is unchanged, since an export already names its images this way.
1617
- 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.
1718
- 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.
1819
- The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects.

‎docs/source/filters.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,18 @@ def generate_filters_docs():
3737
pdf_file = f"../../{'../' if os.getenv('GITHUB_ACTIONS') == 'true' else './'}{static_pdf_directory}/{filter_name}.pdf"
3838

3939
if shutil.which("pdflatex"):
40+
example = Path(filter_module.__file__).parent / "example.tex"
4041
subprocess.run(
4142
[
4243
"pdflatex",
43-
f"-output-directory={static_pdf_directory}",
44+
f"-output-directory={static_pdf_directory.resolve()}",
4445
f"-jobname={filter_name}",
4546
"-interaction=nonstopmode",
46-
tex_file,
47+
example.name,
4748
],
49+
# An example naming a figure names it as it sits beside the document,
50+
# so the document is compiled from its own directory.
51+
cwd=example.parent,
4852
check=True,
4953
)
5054

‎docs/source/question-format.md‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,9 @@ A few things the example shows in passing:
100100
- **Unset question settings are left out of the JSON** rather than guessed at, so `skill`,
101101
`guidance` and the two durations only appear when set. `publish` and the four `display_*`
102102
settings always do, defaulting to `True`.
103-
- **Images** go in `Question.images` as paths on disk; they are copied into `media/` keeping the
104-
file name they already had, and referred to from the markdown by that name.
103+
- **Images** go in `Question.images` as paths on disk; they are copied into `media/` under the file
104+
name they already had, and every reference to one in the question's markdown is rewritten to that
105+
name, which is all Lambda Feedback looks an image up by.
105106
- **{meth}`Set.from_json <in2lambda.api.set.Set.from_json>`** reads an existing export, as a folder
106107
or a zip, so an edit to a real set can start from what Lambda Feedback produced.
107108

@@ -111,14 +112,16 @@ A few things the example shows in passing:
111112
<set name>/set_<Name>.json
112113
<set name>/question_000_<Title>.json # 000 is the question's orderNumber
113114
<set name>/question_001_...
114-
<set name>/media/rocket-momentum.png # one per Question.images path
115+
<set name>/media/rocket-momentum.png # one per file Question.images names
115116
<set name>.zip # the folder, zipped, to upload
116117
```
117118

118119
A question's filename is its title with spaces and the characters Windows and path separators
119120
forbid (`/ \ < > : " | ? *`) each replaced by an underscore. An image keeps the file name it
120-
already had, so `images=["figures/rocket-momentum.png"]` gives `media/rocket-momentum.png`.
121-
Files are written on a single line.
121+
already had, so `images=["figures/rocket-momentum.png"]` gives `media/rocket-momentum.png`, and the
122+
references to it are rewritten to that name. `media/` is one flat folder for the whole set, so a
123+
file two questions use is copied once, and a second file of a name already taken is named as Lambda
124+
Feedback names one, `question_001_<Title>_0001.png`. Files are written on a single line.
122125

123126
### Set
124127

@@ -170,9 +173,11 @@ An `id` left unset is a fresh UUID, which is what import needs.
170173
Maths is `$...$` inline and `$$` on its own lines for display, rendered by
171174
[KaTeX](https://katex.org/): commands KaTeX lacks do not display — degrees, for example, are
172175
written `^\circ`. An image is written `![pictureTag](rocket-momentum.png)`, naming the file as it
173-
sits in `media/`. A filter instead passes through whatever path the source document used, so
176+
sits in `media/`. A filter passes through whatever path the source document used, so
174177
`\includegraphics{figures/rocket-momentum.png}` becomes `![pictureTag](figures/rocket-momentum.png)`
175-
beside `media/rocket-momentum.png`.
178+
in the set; writing the set out rewrites it to `![pictureTag](rocket-momentum.png)`, which is the
179+
image as `media/` holds it. A reference naming no image of the question is left as written, and
180+
{func}`~in2lambda.validation.validate` reports it.
176181

177182
:::{note}
178183
Lambda Feedback's own exports carry a few keys in2lambda neither reads nor writes, among them

‎in2lambda/draft/export.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
from in2lambda.api.part import Part
2424
from in2lambda.api.question import Question
2525
from in2lambda.api.set import Set
26-
from in2lambda.json_convert.json_convert import _question_stem, _question_title
26+
from in2lambda.json_convert.json_convert import _IMAGE, _question_stem, _question_title
2727
from in2lambda.source import ConversionToolsMissing, SourceError, frozen
28-
from in2lambda.validation import _IMAGE, _location, pdf
28+
from in2lambda.validation import _location, pdf
2929

3030
_QUESTION = re.compile(r"q(\d+)\.text")
3131
"""A question's text, and the number that orders it."""

‎in2lambda/filters/PartsOneSol/example.tex‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
\documentclass[12pt]{article}
22

33
\usepackage{comment}
4+
\usepackage{graphicx}
45

56
% This is a common method for including/excluding solutions from the PDF.
67
\includecomment{solution}
@@ -15,7 +16,7 @@ \subsection{}
1516
Here is some preliminary question information that might be useful.
1617

1718
\begin{enumerate}
18-
\item This is part (a)
19+
\item This is part (a), and the apparatus is shown in \includegraphics{./figures/pistons.png}
1920
\item The filter still works even if there aren't any parts
2021
\end{enumerate}
2122

79 Bytes
Loading

‎in2lambda/json_convert/json_convert.py‎

Lines changed: 100 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import zipfile
99
from copy import deepcopy
1010
from pathlib import Path
11-
from typing import Any
11+
from typing import Any, Optional
1212

1313
from in2lambda.api.part import Part
1414
from in2lambda.api.question import Question
@@ -19,6 +19,33 @@
1919
MINIMAL_QUESTION_TEMPLATE = "minimal_template_question.json"
2020
MINIMAL_SET_TEMPLATE = "minimal_template_set.json"
2121

22+
_IMAGE = re.compile(r"!\[[^\]]*\]\(([^)]*)\)")
23+
"""A markdown image, e.g. ``![pictureTag](question_000_Title_0001.png)``."""
24+
25+
26+
def _image_for(reference: str, images: list[str]) -> Optional[str]:
27+
"""Which of a question's images a markdown reference names, if any.
28+
29+
Matched by file name, because that is the link between the two: a filter resolves
30+
the very path it leaves in the markdown, and Lambda Feedback finds an image in
31+
``media/`` by its file name alone. Only where a question lists two files of the same
32+
name does the rest of the reference decide, by naming the end of one of their paths.
33+
34+
Returns:
35+
The image, or None if the question lists none of that name - in which case the
36+
reference is left as written, which :mod:`in2lambda.validation` reports.
37+
"""
38+
named = [image for image in images if Path(image).name == Path(reference).name]
39+
if len(named) > 1:
40+
# A filter keeps the reference as the document wrote it but normalises the path
41+
# it lists beside it, so a `..` is present on one side only and has to come off
42+
# for the two to line up. A `.` is already gone, dropped by ``pathlib``.
43+
parts = tuple(part for part in Path(reference).parts if part != "..")
44+
named = [
45+
image for image in named if Path(image).parts[-len(parts) :] == parts
46+
] or named
47+
return named[0] if named else None
48+
2249

2350
def _templates() -> tuple[dict[str, Any], dict[str, Any]]:
2451
"""Loads the minimal question and set templates that the writer fills in.
@@ -47,9 +74,8 @@ def _zip(files: list[Path], root: Path, zip_path: str) -> None:
4774
root: The folder the archive names are relative to.
4875
zip_path: The path where the zip file will be created.
4976
"""
50-
# Sort by archive name for deterministic, alphabetical order. A file can be
51-
# written more than once — an image used by both a question and its worked
52-
# solution — and is still one file on disk, so name it once here too.
77+
# Sort by archive name for deterministic, alphabetical order, and name each file
78+
# once: a file written twice is still one file on disk.
5379
names = sorted({str(file.relative_to(root)): file for file in files}.items())
5480
with zipfile.ZipFile(zip_path, "w") as zf:
5581
for name, file in names:
@@ -226,8 +252,55 @@ def _question_json(
226252
return output
227253

228254

255+
def _media_name(image: str, stem: str, taken: set[str]) -> str:
256+
"""What an image is called in ``media/``, which is flat and so has one of each name.
257+
258+
Its own file name, or, where that name is another file's already, the name Lambda
259+
Feedback's own exports give an image: the question's, numbered.
260+
"""
261+
name = Path(image).name
262+
if name not in taken:
263+
return name
264+
number = 1
265+
while (numbered := f"{stem}_{number:04}{Path(image).suffix}") in taken:
266+
number += 1
267+
return numbered
268+
269+
270+
def _with_media_names(value: Any, question: Question, media: dict[str, str]) -> Any:
271+
"""A question's JSON with every image reference in it rewritten to its media name.
272+
273+
Walked rather than taken field by field because a reference can be written in any
274+
markdown the question holds - its text, a part's, a worked solution, a final answer,
275+
an answer box's wording or one of its options - and a second list of those here would
276+
drift from the one :mod:`in2lambda.validation` already checks.
277+
"""
278+
if isinstance(value, dict):
279+
return {
280+
key: _with_media_names(item, question, media) for key, item in value.items()
281+
}
282+
if isinstance(value, list):
283+
return [_with_media_names(item, question, media) for item in value]
284+
if not isinstance(value, str):
285+
return value
286+
287+
def rewrite(reference: re.Match[str]) -> str:
288+
image = _image_for(reference[1], question.images)
289+
if image is None:
290+
return reference[0]
291+
# Only the path is replaced; the alt text beside it may well read the same.
292+
name = media[os.path.abspath(image)]
293+
return reference[0][: reference.start(1) - reference.start()] + name + ")"
294+
295+
return _IMAGE.sub(rewrite, value)
296+
297+
229298
def _write_question(
230-
question: Question, i: int, template: dict[str, Any], folder: Path
299+
question: Question,
300+
i: int,
301+
template: dict[str, Any],
302+
folder: Path,
303+
media: dict[str, str],
231304
) -> list[Path]:
232305
"""Writes one question's JSON, and any images it uses, into an existing folder.
233306
@@ -236,25 +309,32 @@ def _write_question(
236309
i: Its order number, which also prefixes the file name.
237310
template: The loaded JSON from the minimal question template.
238311
folder: The folder to write into.
312+
media: What the export has carried into ``media/`` so far, each image's path on
313+
disk against the name it was written under. Added to as this question's
314+
images are copied, so that a file two questions use is one file under one
315+
name.
239316
240317
Returns:
241318
The files written.
242319
"""
243320
output = _question_json(question, i, template)
321+
stem = _question_stem(i, output["title"])
244322

245-
json_file = folder / f"{_question_stem(i, output['title'])}.json"
246-
with open(json_file, "w") as file:
247-
json.dump(output, file)
248-
written = [json_file]
249-
323+
written = []
250324
for image in question.images:
251-
# If images exist, create a media directory
252-
media = folder / "media"
253-
media.mkdir(exist_ok=True)
254-
# The JSON refers to an image by its file name, so copying keeps that name.
255-
written.append(Path(shutil.copy(os.path.abspath(image), media)))
325+
path = os.path.abspath(image)
326+
if path in media:
327+
continue
328+
media[path] = _media_name(path, stem, set(media.values()))
329+
# Only a question with an image gets a media folder at all.
330+
(folder / "media").mkdir(exist_ok=True)
331+
written.append(Path(shutil.copy(path, folder / "media" / media[path])))
332+
333+
json_file = folder / f"{stem}.json"
334+
with open(json_file, "w") as file:
335+
json.dump(_with_media_names(output, question, media), file)
256336

257-
return written
337+
return [json_file] + written
258338

259339

260340
def write_question(question: Question, output_dir: str, number: int = 0) -> None:
@@ -275,7 +355,7 @@ def write_question(question: Question, output_dir: str, number: int = 0) -> None
275355
number, _question_title(question, number)
276356
)
277357
folder.mkdir(parents=True, exist_ok=True)
278-
written = _write_question(question, number, question_template, folder)
358+
written = _write_question(question, number, question_template, folder, {})
279359
_zip(written, folder, f"{folder}.zip")
280360

281361

@@ -320,8 +400,10 @@ def converter(
320400
json.dump(set_template, file)
321401

322402
written = [set_file]
403+
# Named across the whole set, since media/ is one folder for all of its questions.
404+
media: dict[str, str] = {}
323405
for i, question in enumerate(ListQuestions):
324-
written += _write_question(question, i, question_template, folder)
406+
written += _write_question(question, i, question_template, folder, media)
325407

326408
# output zip file in destination folder
327409
_zip(written, folder, output_question + ".zip")

‎in2lambda/main.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def runner(
110110
>>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsSepSol/example.tex", "PartsSepSol") # doctest: +ELLIPSIS
111111
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'), ...])
112112
>>> runner(f"{os.path.dirname(in2lambda.__file__)}/filters/PartsOneSol/example.tex", "PartsOneSol") # doctest: +ELLIPSIS
113-
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.'), ...])
113+
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.'), ...])
114114
"""
115115
_require_conversion_tools()
116116
import panflute as pf

0 commit comments

Comments
 (0)