Skip to content

Commit 6a2ede6

Browse files
Build only after a clean validate (t35)
Build only after a clean validate
2 parents 507c092 + 8cc5fd6 commit 6a2ede6

20 files changed

Lines changed: 960 additions & 71 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@
1111
- `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.
1212
- `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.
1313
- `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, parts nothing answers, and fields holding nothing. Each finding names 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.
14+
- `in2lambda build` writes the draft in this directory 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, and the refusal prints what the report says so it can be acted on without opening the draft. `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.
1415
- The Python API is unchanged: `in2lambda.main.runner` and everything under `in2lambda.api` take the same arguments and return the same objects.

‎in2lambda/draft/export.py‎

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
"""Turns a finished draft into the set it describes, to upload or to look at.
2+
3+
A draft is a map of fields - ``q1.text``, ``q1.p2.text``, ``q1.solution`` - and an
4+
export is a :class:`~in2lambda.api.set.Set` of questions holding parts. :func:`as_set`
5+
is the one place that reads the one as the other, so both what is written out and what
6+
is rendered for review come from the same reading of the draft.
7+
8+
:func:`build` refuses a draft the checks have not looked at, or have something to say
9+
about. There is no timestamp in that: every command that changes a draft takes its
10+
report with it, so a draft holding one has been checked since it last changed, and
11+
`in2lambda.source.frozen` refuses one whose source has moved on underneath it.
12+
:func:`render` is gated on nothing, since looking at a draft is how what the checks
13+
found gets fixed.
14+
"""
15+
16+
import re
17+
import warnings
18+
from pathlib import Path
19+
from typing import Any
20+
21+
from in2lambda.api.part import Part
22+
from in2lambda.api.question import Question
23+
from in2lambda.api.set import Set
24+
from in2lambda.json_convert.json_convert import _question_stem, _question_title
25+
from in2lambda.source import DRAFT, ConversionToolsMissing, SourceError, frozen
26+
from in2lambda.validation import _IMAGE, pdf
27+
28+
_QUESTION = re.compile(r"q(\d+)\.text")
29+
"""A question's text, and the number that orders it."""
30+
31+
_PART = re.compile(r"q(\d+)\.p(\d+)\.text")
32+
"""A part's text, and the question and part numbers that order it."""
33+
34+
35+
class NotValidated(SourceError):
36+
"""A draft is being exported that the checks have not passed, or not seen at all."""
37+
38+
39+
class MissingImage(SourceError):
40+
"""A field refers to an image file that is not beside the draft.
41+
42+
The checks do not look at files, so such a draft validates clean; it is refused
43+
here rather than exported, since what would be uploaded is a question with a broken
44+
figure in it.
45+
"""
46+
47+
48+
def as_set(draft: dict[str, Any], directory: str = ".") -> Set:
49+
r"""The set a draft's fields describe, in question and part order.
50+
51+
Args:
52+
draft: A draft, as `in2lambda.source.frozen` reads one.
53+
directory: Where the draft is, and so what the images it names are beside.
54+
55+
Returns:
56+
One question per ``qN.text``, holding one part per ``qN.pM.text`` with the
57+
worked solution written for it. A question's own ``qN.solution`` answers every
58+
part that has none of its own; where every part has one already, or the
59+
question was written without parts, it becomes a part of its own holding
60+
nothing but that solution. That is the rule
61+
:meth:`~in2lambda.api.question.Question.add_solution` applies, so a draft
62+
exports as the same sheet converted by `in2lambda convert` does. A question
63+
written with neither parts nor a solution holds one part with nothing in it,
64+
which is the question as the draft has it. A block marked ignore is in no
65+
question: it is the source's, not the set's.
66+
67+
Examples:
68+
>>> from in2lambda.draft.export import as_set
69+
>>> fields = {
70+
... "q1.text": {"value": "Water flows through a pipe."},
71+
... "q1.p1.text": {"value": "State the continuity equation."},
72+
... "q1.solution": {"value": "$Q = \\pi d^2 v / 4$."},
73+
... "b1.ignore": {"value": True},
74+
... }
75+
>>> as_set({"fields": fields}).questions
76+
[Question(title='', parts=[Part(text='State the continuity equation.', worked_solution='$Q = \\pi d^2 v / 4$.', answer='', response_areas=[])], images=[], main_text='Water flows through a pipe.')]
77+
"""
78+
fields = draft["fields"]
79+
question_set = Set()
80+
for number in sorted(
81+
int(found[1]) for key in fields if (found := _QUESTION.fullmatch(key))
82+
):
83+
question_set.add_question(main_text=fields[f"q{number}.text"]["value"])
84+
question = question_set.questions[-1]
85+
for part in sorted(
86+
int(found[2])
87+
for key in fields
88+
if (found := _PART.fullmatch(key)) and int(found[1]) == number
89+
):
90+
question.add_part_text(fields[f"q{number}.p{part}.text"]["value"])
91+
if (written := f"q{number}.p{part}.solution") in fields:
92+
question.parts[-1].worked_solution = fields[written]["value"]
93+
if (written := f"q{number}.solution") in fields:
94+
# A sheet often writes one worked solution for a whole question, which
95+
# answers each part it does not answer separately. Where nothing is left for
96+
# it to answer it is a part of its own, as `add_solution` makes it one: a
97+
# solution written beside a solution for every part is still the author's
98+
# wording, and dropping it would export less than the draft holds.
99+
if all(part_of.worked_solution for part_of in question.parts):
100+
question.parts.append(Part(worked_solution=fields[written]["value"]))
101+
else:
102+
for part_of in question.parts:
103+
if not part_of.worked_solution:
104+
part_of.worked_solution = fields[written]["value"]
105+
if not question.parts:
106+
# A question whose parts are yet to be written is still exported, and an
107+
# empty part is what it holds: `json_convert` leaves a question with no
108+
# parts at all carrying the template's own placeholder wording, which is
109+
# wording no field of the draft holds.
110+
question.parts.append(Part())
111+
# As the export refers to them: beside the draft, since that is where a command
112+
# naming a file names one. Whether the file is there is `build`'s question, not
113+
# asked here, so that a draft can be rendered while its figures are being found.
114+
for _, markdown in _fields(question, number):
115+
question.images += [
116+
str(Path(directory) / reference)
117+
for reference in _IMAGE.findall(markdown)
118+
]
119+
return question_set
120+
121+
122+
def build(directory: str = ".", output_dir: str = "out") -> Path:
123+
"""Writes the draft in a directory out as a Lambda Feedback set, if it is clean.
124+
125+
Args:
126+
directory: Where the ``draft.json`` to export is.
127+
output_dir: Where to write the set's folder and its zip.
128+
129+
Returns:
130+
The zip that was written, which is what Lambda Feedback imports.
131+
132+
Raises:
133+
NotValidated: the draft has not been checked since it last changed, or the
134+
checks found something. Either way what would be uploaded is not what
135+
anybody has looked at.
136+
MissingImage: a field refers to an image file that is not beside the draft.
137+
SourceError: the draft is missing, is not one of ours, or was written from
138+
markdown that has changed since.
139+
"""
140+
draft, _ = frozen(directory)
141+
if "report" not in draft:
142+
raise NotValidated(
143+
f"{DRAFT} has not been validated since it last changed, so what it would "
144+
"export is what nothing has checked. Run in2lambda validate."
145+
)
146+
if draft["report"]:
147+
raise NotValidated(
148+
"\n".join(finding["message"] for finding in draft["report"])
149+
+ f"\n{DRAFT} is not exported while its report says this. Fix what it "
150+
"names, or mark the blocks it is about as ignored, and run in2lambda "
151+
"validate again."
152+
)
153+
exported = as_set(draft, directory)
154+
# The export carries every image a field refers to into media/, which is the only
155+
# place Lambda Feedback looks for one, so a file that is not there is not something
156+
# to write the set without: `json_convert` would raise a bare FileNotFoundError over
157+
# it. The checks read the draft and not the folder it is in, so a draft they found
158+
# nothing in can still say this.
159+
for number, question in enumerate(exported.questions, start=1):
160+
for image in question.images:
161+
if not Path(image).is_file():
162+
raise MissingImage(
163+
f"Question {number} refers to an image, and there is no file at "
164+
f"{image}. Put the image there, or take the reference out of the "
165+
"field with in2lambda draft field replace."
166+
)
167+
exported.to_json(output_dir)
168+
return Path(output_dir) / "set.zip"
169+
170+
171+
def render(directory: str = ".", output_dir: str = "out") -> list[Path]:
172+
"""Writes each question of the draft in a directory as a PDF, for review.
173+
174+
The questions are compiled as Lambda Feedback's own PDF generator compiles them,
175+
under a heading naming each, so what comes out is what a student would be shown.
176+
The checks are not run first: looking at a draft is how what they found gets fixed.
177+
Nor does a figure that is not beside the draft stop a question being looked at -
178+
the compiler drops the reference and typesets the rest of it - or a question the
179+
compiler gives up on altogether stop the rest of the draft being written out.
180+
181+
Args:
182+
directory: Where the ``draft.json`` to render is.
183+
output_dir: Where to write the PDFs, named as the export names its questions.
184+
185+
Returns:
186+
The PDF written for each question that was rendered, in question order.
187+
188+
Raises:
189+
ConversionToolsMissing: pandoc or xelatex is not installed.
190+
CompileFailed: no question could be rendered at all, so there is nothing to
191+
look at.
192+
SourceError: the draft is missing, is not one of ours, or was written from
193+
markdown that has changed since.
194+
195+
Warns:
196+
UserWarning: once per LaTeX error in a question that was rendered anyway, and
197+
once for a question the compiler gave up on while others rendered.
198+
"""
199+
if missing := pdf.missing_tools():
200+
raise ConversionToolsMissing(
201+
f"Rendering questions needs {' and '.join(missing)}."
202+
)
203+
draft, _ = frozen(directory)
204+
205+
written = []
206+
refused = []
207+
for index, question in enumerate(as_set(draft, directory).questions):
208+
stem = _question_stem(index, _question_title(question, index))
209+
output = Path(output_dir) / f"{stem}.pdf"
210+
# Headed with the question's number, so that a stack of these can be read
211+
# through, and so that a question with nothing written in it is still a page.
212+
heading = f"Question {index + 1}"
213+
fields = [(heading, f"# {heading}")] + _fields(question, index + 1)
214+
try:
215+
problems = pdf.render(fields, question.images, output)
216+
except pdf.CompileFailed as failed:
217+
refused.append(str(failed))
218+
continue
219+
for problem in problems:
220+
warnings.warn(str(problem), stacklevel=2)
221+
written.append(output)
222+
if refused and not written:
223+
# Nothing at all to look at, which is a failed run rather than a fault in one
224+
# question of it, so it is said the way a draft that cannot be read is.
225+
raise pdf.CompileFailed("; ".join(refused))
226+
for failure in refused:
227+
# One question TeX cannot finish is a fault in that question like any other, and
228+
# the ones that do compile are still what the draft is being rendered for.
229+
warnings.warn(failure, stacklevel=2)
230+
return written
231+
232+
233+
def _fields(question: Question, number: int) -> list[tuple[str, str]]:
234+
"""Every markdown field of a question, each with where to report an error in it.
235+
236+
Named as `in2lambda.validation` names them, save for the title, since a draft
237+
writes none: the renderer marks the document with these, so what it reports back
238+
reads the same as what the validator reports.
239+
"""
240+
where = f"Question {number}"
241+
fields = [(f"{where}, main text", question.main_text)]
242+
for index, part in enumerate(question.parts):
243+
part_where = f"{where}, part ({chr(ord('a') + index)})"
244+
fields += [
245+
(f"{part_where}, text", part.text),
246+
(f"{part_where}, worked solution", part.worked_solution),
247+
]
248+
return fields

‎in2lambda/main.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import rich_click as click
2121

2222
import in2lambda.draft
23+
import in2lambda.draft.export
2324
import in2lambda.draft.report
2425
import in2lambda.filters
2526
import in2lambda.source
@@ -434,5 +435,51 @@ def validate() -> None:
434435
click.echo("Nothing to report.")
435436

436437

438+
_out = click.option(
439+
"--out",
440+
"-o",
441+
"output_dir",
442+
default="./out",
443+
show_default=True,
444+
help="Directory to write the files to.",
445+
type=click.Path(resolve_path=True),
446+
)
447+
"""Where what a command makes is written, as `convert` has always taken it."""
448+
449+
450+
@cli.command("build")
451+
@_out
452+
def build(output_dir: str) -> None:
453+
"""Writes the draft in this directory out as a Lambda Feedback set.
454+
455+
Refused unless in2lambda validate has been run since the draft last changed and
456+
found nothing, so that what is uploaded is what the checks have been over.
457+
"""
458+
with _message_not_traceback():
459+
written = in2lambda.draft.export.build(output_dir=output_dir)
460+
click.echo(f"Wrote {written}")
461+
462+
463+
@cli.command("render")
464+
@_out
465+
def render(output_dir: str) -> None:
466+
"""Writes each question of the draft in this directory as a PDF, for review.
467+
468+
The questions are compiled as Lambda Feedback's PDF generator compiles them, which
469+
needs pandoc and xelatex. What the checks have to say about the draft is not asked:
470+
a draft is rendered to look at, including one there is something to fix in.
471+
"""
472+
with _message_not_traceback():
473+
# As `runner` does: a question xelatex complains about is still written out, and
474+
# what it refused is a line to read rather than a traceback.
475+
with warnings.catch_warnings(record=True) as refused:
476+
warnings.simplefilter("always")
477+
written = in2lambda.draft.export.render(output_dir=output_dir)
478+
for warning in refused:
479+
click.echo(f"Warning: {warning.message}")
480+
for pdf in written:
481+
click.echo(f"Wrote {pdf}")
482+
483+
437484
if __name__ == "__main__":
438485
cli()

0 commit comments

Comments
 (0)